From 0da07cb06505af8dd665ecdbe18975417220c2ef Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 15 Jul 2026 19:06:49 +0800 Subject: [PATCH 01/15] =?UTF-8?q?fix(compiler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=B9=E6=9C=AA=E5=AE=9A=E4=B9=89=E5=8F=98=E9=87=8F=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20=3F=3F=3D=20=E6=97=B6=E7=9A=84=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Parser/AssignOpTrait.php | 14 +++++++++++--- .../coalesce/assign-coalesce-undefined-var.phpt | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/compiler/coalesce/assign-coalesce-undefined-var.phpt diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index cf826761..826cd011 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -862,6 +862,17 @@ trait AssignOpTrait protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string { $this->checkLeftValue($expr->var); + + // PHP 允许对未定义的简单变量使用 ??=(例如 `$a ??= 123`): + // 此时 isset 为 false,直接执行赋值。需要提前声明该局部变量, + // 否则 isset 检查会因变量未定义而报错。此处必须声明为 Type::VAR + // (Variant),使其初值为 NULL,从而 isset 在运行时正确判定为 + // false 并执行赋值;若使用原生类型,isset 恒为 true 会导致取到 + // 未初始化的默认值(如 int(0)、空字符串)。 + if ($this->isVarExpr($expr->var) and !$this->hasVar($this->parseIdentifier($expr->var))) { + $this->addLocalVar($this->parseIdentifier($expr->var), Type::VAR); + } + $isset = $this->parseChainedExpr($expr->var, self::OP_ISSET); $var = $this->parseWritableIdentifier($expr->var); @@ -878,9 +889,6 @@ trait AssignOpTrait if ($this->isVarExpr($expr->expr) and !$this->hasVar($right)) { $this->errorUndefinedVariable($expr->expr); } - if ($this->isVarExpr($expr->var) and !$this->hasVar($var)) { - $this->addLocalVar($var, $this->getNormalAssignType($this->detectTypeOfExpr($expr->expr))); - } return '(' . $isset . '?' . $var . ':(' . $var . ' = ' . $right . '))'; } diff --git a/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt b/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt new file mode 100644 index 00000000..4aad8edb --- /dev/null +++ b/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt @@ -0,0 +1,14 @@ +--TEST-- +assign coalesce on undefined variable +--FILE-- + +--EXPECT-- +int(123) +string(3) "foo" From 72cbdc500a1805dda5180ef0e7c714d47afcba6a Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 15 Jul 2026 19:20:57 +0800 Subject: [PATCH 02/15] =?UTF-8?q?fix(compiler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=85=88=E5=90=8E=E5=BD=B1=E5=93=8D=E8=B7=A8?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E6=8E=A5=E5=8F=A3=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Translator.php | 4 +- .../interface-impl-param-type-cross-ns.phpt | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt diff --git a/src/Translator.php b/src/Translator.php index 676618a3..ce370f1e 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3249,8 +3249,8 @@ CODE; Type::STR => [['kind' => 'isString']], Type::ARRAY => [['kind' => 'isArray']], Type::RESOURCE => [['kind' => 'isResource']], - Type::OBJECT => $arg->class - ? [['kind' => 'instanceof', 'class' => $arg->class]] + Type::OBJECT => $arg->declaredClass + ? [['kind' => 'instanceof', 'class' => $arg->declaredClass]] : [['kind' => 'isObject']], default => null, }; diff --git a/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt b/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt new file mode 100644 index 00000000..acaec9e6 --- /dev/null +++ b/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt @@ -0,0 +1,48 @@ +--TEST-- +Cross-namespace interface implementation with an interface-typed parameter must not be reported as incompatible +--FILE-- +test(new \B\Impl1())); + echo "done\n"; + } +} +?> +--EXPECT-- +bool(true) +done From f9516c43c98226a4d33cb0d3377feadbdab58dbe Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 15 Jul 2026 19:47:04 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(compiler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=B8=ADself=E8=BF=94=E5=9B=9E=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CompilerBase.php | 5 ++- .../class/interface-method-self-return.phpt | 42 +++++++++++++++++++ .../interface-self-return-namespaced.phpt | 36 ++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/compiler/class/interface-method-self-return.phpt create mode 100644 tests/compiler/namespace/interface-self-return-namespaced.phpt diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 074a1517..fab95bf8 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1017,7 +1017,10 @@ class CompilerBase implements PropertyAccessContext protected function getFullClassName(): string { - return ltrim($this->namespace . '\\' . $this->class, '\\'); + // 在接口上下文中,$this->class 为空但 $this->interface 已设置, + // `self` 类型声明应解析为接口自身的完整名称。 + $classLike = $this->class !== '' ? $this->class : $this->interface; + return ltrim($this->namespace . '\\' . $classLike, '\\'); } protected function getFullMethodName(string $fullClassName, string $method): string diff --git a/tests/compiler/class/interface-method-self-return.phpt b/tests/compiler/class/interface-method-self-return.phpt new file mode 100644 index 00000000..90e44eab --- /dev/null +++ b/tests/compiler/class/interface-method-self-return.phpt @@ -0,0 +1,42 @@ +--TEST-- +interface method with `self` return type implemented by class (fluent interface), and namespace block containing comments +--FILE-- +value = $value; + return $this; + } + } + + function main() + { + $test = new TestClass; + // get() returns self, so the result still satisfies the interface + var_dump($test->get() instanceof TestInterface); + var_dump($test === $test->get()); + // fluent chaining of self-returning methods + var_dump($test->get()->setValue(42)->value); + } +} +?> +--EXPECT-- +bool(true) +bool(true) +int(42) diff --git a/tests/compiler/namespace/interface-self-return-namespaced.phpt b/tests/compiler/namespace/interface-self-return-namespaced.phpt new file mode 100644 index 00000000..cfda0bad --- /dev/null +++ b/tests/compiler/namespace/interface-self-return-namespaced.phpt @@ -0,0 +1,36 @@ +--TEST-- +interface method `self` return type resolves to the interface's fully-qualified name inside a named namespace +--FILE-- +log[] = 'chain'; + return $this; + } + } +} + +namespace { + function main() + { + $w = new \App\Widget(); + var_dump($w->chain()->chain() instanceof \App\Chainable); + var_dump(count($w->log)); + } +} +?> +--EXPECT-- +bool(true) +int(2) From 6daa3121834cf183c859f11755ab1866e7e1c52f Mon Sep 17 00:00:00 2001 From: Yurun Date: Wed, 15 Jul 2026 21:17:52 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix(compiler):=20=E6=8C=89=E5=80=BC?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E5=BC=95=E7=94=A8=E8=BF=94=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E6=AD=A3=E7=A1=AE=E5=88=86=E7=A6=BB=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CompilerBase.php | 22 +++++++++ src/Generator/CallArgumentGenerator.php | 7 +++ src/Parser/ArrayExpressionTrait.php | 4 +- .../dynamic-return-reference-argument.phpt | 46 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/compiler/ref/dynamic-return-reference-argument.phpt diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 074a1517..5888454c 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1066,6 +1066,28 @@ class CompilerBase implements PropertyAccessContext return $this->wrapVoidExprAsNull($expr, $this->parseExpr($expr)); } + /** + * 把一个"按引用返回"的调用在按值消费处解引用为值快照。 + * + * 返回引用的调用会产生一个指向被调用方存储的活引用。当该调用被按值消费 + * (例如作为按值函数参数、数组元素、按值返回等会触发 PHP 分离语义的上下文) + * 时,PHP 会在求值那一刻拷贝出值的快照,因此之后对别名存储的修改不应再可见。 + * 我们通过把结果赋值给一个临时 php::Var 来分离引用(普通的 Var 赋值会断开 + * 引用,而 php::Variant(php::Ref) 构造会保留引用),从而保留从左到右的求值顺序。 + * + * 注意:二元/一元运算等操作数上下文应保持引用活动、在运算时读值,不应在此快照; + * 那些上下文由各自的解析器直接保留引用。 + */ + protected function materializeRefReturnAsValue(NodeAbstract $value, string $expr): string + { + if ($value instanceof Expr\CallLike && $this->resolveRefReturningCall($value) !== false) { + $tmpVar = $this->addTmpVar(Type::VAR); + $this->context->beforeStmtLines[] = $tmpVar . ' = ' . $expr . ';'; + return $tmpVar; + } + return $expr; + } + protected function getObjectPropVarName(string $object, string $prop): string { return self::OBJECT_PROP . $object . self::NAMESPACE_SEPARATOR . $prop; diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 0347667e..79431f24 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -586,6 +586,13 @@ trait CallArgumentGenerator protected function materializeCallArgValue(NodeAbstract $value, string $expr): string { + // A call that returns by reference yields a live php::Ref aliasing the + // callee's storage. When such a call feeds a by-value argument, PHP takes + // a value snapshot at evaluation time (left to right), so later mutations + // to the aliased storage must not be observable. The dynamic ArgList keeps + // references verbatim (Ctor::CopyRef), so we dereference into a temporary + // value at the point of the call. + $expr = $this->materializeRefReturnAsValue($value, $expr); if (!$this->shouldMaterializeCallArg($value)) { return $expr; } diff --git a/src/Parser/ArrayExpressionTrait.php b/src/Parser/ArrayExpressionTrait.php index a920f62b..819cf8cf 100644 --- a/src/Parser/ArrayExpressionTrait.php +++ b/src/Parser/ArrayExpressionTrait.php @@ -60,7 +60,7 @@ trait ArrayExpressionTrait $this->indentLevel++; foreach ($items as $item) { $this->assertExprCanBeUsedAsValue($item->value, 'array value'); - $value = $this->parseIdentifier($item->value); + $value = $this->materializeRefReturnAsValue($item->value, $this->parseIdentifier($item->value)); if ($item->key) { $this->assertExprCanBeUsedAsValue($item->key, 'array key'); $key = $this->parseArrayKey($item->key); @@ -221,7 +221,7 @@ trait ArrayExpressionTrait } $value = $this->convertToRef($item->value); } else { - $value = $this->parseIdentifier($item->value); + $value = $this->materializeRefReturnAsValue($item->value, $this->parseIdentifier($item->value)); } if ($item->unpack) { $this->context->beforeStmtLines[] = $this->getIndent() . $tmpVar . '.merge(' . $value . ');'; diff --git a/tests/compiler/ref/dynamic-return-reference-argument.phpt b/tests/compiler/ref/dynamic-return-reference-argument.phpt new file mode 100644 index 00000000..471881ef --- /dev/null +++ b/tests/compiler/ref/dynamic-return-reference-argument.phpt @@ -0,0 +1,46 @@ +--TEST-- +Reference-returning calls are copied by value when used as call arguments or array elements +--FILE-- + +--EXPECT-- +int(1) +int(2) +int(2) +int(2) +int(0) +int(0) +int(1) +int(2) +array(2) { + [0]=> + int(3) + [1]=> + int(4) +} From 00023cc3decbb39eabf6c8fc6aece5dd35ef165c Mon Sep 17 00:00:00 2001 From: Yurun Date: Sun, 19 Jul 2026 08:55:16 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(compiler):=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E6=89=80=E6=9C=89$$=E8=AF=AD=E6=B3=95=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E4=BB=85=E7=89=B9=E5=AE=9A=E8=A1=A8=E8=BE=BE=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpunit/code/variable-variable-arraydim.php | 8 ++++++++ phpunit/src/VariableVariableTest.php | 9 +++++++++ src/CompilerBase.php | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 phpunit/code/variable-variable-arraydim.php create mode 100644 phpunit/src/VariableVariableTest.php diff --git a/phpunit/code/variable-variable-arraydim.php b/phpunit/code/variable-variable-arraydim.php new file mode 100644 index 00000000..9a5db13f --- /dev/null +++ b/phpunit/code/variable-variable-arraydim.php @@ -0,0 +1,8 @@ + 'hello']; + ${$foo['bar']} = 'world'; + echo $hello; +} diff --git a/phpunit/src/VariableVariableTest.php b/phpunit/src/VariableVariableTest.php new file mode 100644 index 00000000..f3ae8102 --- /dev/null +++ b/phpunit/src/VariableVariableTest.php @@ -0,0 +1,9 @@ +exec('The `$$` syntax is not supported', 'variable-variable-arraydim.php'); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 074a1517..6381503d 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1273,7 +1273,7 @@ class CompilerBase implements PropertyAccessContext protected function parseVariable(Variable $expr): string { - if (is_object($expr->name) and $this->isVarExpr($expr->name)) { + if (is_object($expr->name)) { $this->fatalError($expr, 'The `$$` syntax is not supported'); } if ($this->isSuperGlobal($expr->name)) { From 9d22e8ca246f6336a71735d0a94d9d36b823d3c6 Mon Sep 17 00:00:00 2001 From: Yurun Date: Sun, 19 Jul 2026 09:03:49 +0800 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dtrait=E4=B8=ADpa?= =?UTF-8?q?rent::=E8=B0=83=E7=94=A8=E5=8F=8A=E6=96=B9=E6=B3=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=85=BC=E5=AE=B9=E6=80=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- phpunit/code/parent-method-private.php | 23 ++++ .../trait-method-override-incompatible.php | 24 +++++ phpunit/code/trait-parent-method-private.php | 28 +++++ .../code/trait-parent-method-protected.php | 28 +++++ phpunit/src/ClassTest.php | 17 +++ phpunit/src/InheritanceErrorTest.php | 9 ++ src/Entity/MethodDef.php | 16 +++ src/Parser/MethodCallTrait.php | 26 +++++ src/Translator.php | 102 ++++++++++++++++++ .../trait/trait-parent-constructor.phpt | 36 +++++++ .../trait/trait-parent-method-protected.phpt | 34 ++++++ tests/compiler/trait/trait-parent-method.phpt | 34 ++++++ 12 files changed, 377 insertions(+) create mode 100644 phpunit/code/parent-method-private.php create mode 100644 phpunit/code/trait-method-override-incompatible.php create mode 100644 phpunit/code/trait-parent-method-private.php create mode 100644 phpunit/code/trait-parent-method-protected.php create mode 100644 tests/compiler/trait/trait-parent-constructor.phpt create mode 100644 tests/compiler/trait/trait-parent-method-protected.phpt create mode 100644 tests/compiler/trait/trait-parent-method.phpt diff --git a/phpunit/code/parent-method-private.php b/phpunit/code/parent-method-private.php new file mode 100644 index 00000000..cd030d1e --- /dev/null +++ b/phpunit/code/parent-method-private.php @@ -0,0 +1,23 @@ +reveal()); +} diff --git a/phpunit/code/trait-method-override-incompatible.php b/phpunit/code/trait-method-override-incompatible.php new file mode 100644 index 00000000..eb4de957 --- /dev/null +++ b/phpunit/code/trait-method-override-incompatible.php @@ -0,0 +1,24 @@ +reveal()); +} diff --git a/phpunit/code/trait-parent-method-protected.php b/phpunit/code/trait-parent-method-protected.php new file mode 100644 index 00000000..1a94d8b9 --- /dev/null +++ b/phpunit/code/trait-parent-method-protected.php @@ -0,0 +1,28 @@ +greet('World')); +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index a2c5de87..96c1e4a9 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -37,6 +37,23 @@ class ClassTest extends \BaseTest $this->exec('Cannot override private method `Base::doWork()`', 'override-private-method.php'); } + public function testTraitMayCallProtectedParentMethod() + { + // A protected parent method is reachable via parent:: from a trait, + // matching PHP runtime behaviour. + $this->compile('trait-parent-method-protected.php'); + } + + public function testCannotAccessPrivateParentMethodFromRegularClass() + { + $this->exec('Cannot access private method `Base::secret()`', 'parent-method-private.php'); + } + + public function testCannotAccessPrivateParentMethodFromTrait() + { + $this->exec('Cannot access private method `BaseSecret::secret()`', 'trait-parent-method-private.php'); + } + public function testSelfCanBePartOfUnionType() { global $translator; diff --git a/phpunit/src/InheritanceErrorTest.php b/phpunit/src/InheritanceErrorTest.php index 6c537f0c..18447b7d 100644 --- a/phpunit/src/InheritanceErrorTest.php +++ b/phpunit/src/InheritanceErrorTest.php @@ -306,4 +306,13 @@ class InheritanceErrorTest extends TestCase { $this->exec('must be compatible', 'abstract_method_signature_mismatch.php'); } + + public function testTraitMethodMustBeCompatibleWithParent() + { + // A trait method flattened into a class must remain signature-compatible + // with any same-named parent method, just like a directly-declared + // override. Without this check the incompatibility only surfaces as a + // runtime fatal error that the compiled binary would otherwise ignore. + $this->exec('must be compatible', 'trait-method-override-incompatible.php'); + } } diff --git a/src/Entity/MethodDef.php b/src/Entity/MethodDef.php index 41bbecba..3973e385 100644 --- a/src/Entity/MethodDef.php +++ b/src/Entity/MethodDef.php @@ -15,6 +15,22 @@ class MethodDef public ?FunctionDef $functionDef = null; public bool $hasDynamicCall = false; + /** + * The original `ClassMethod` AST node this definition was parsed from. + * Stored so that later validation (e.g. trait method override compatibility + * checks performed at the `use` site) can report accurate line information. + */ + public ?\PhpParser\Node\Stmt\ClassMethod $node = null; + + /** + * For methods defined inside a trait, records `parent::method()` calls so + * the compiler can validate their visibility against the parent of each + * class that uses the trait (the trait itself has no parent at compile time). + * + * @var array + */ + public array $parentMethodCalls = []; + public function __construct(int $flags, string $name) { $this->flags = $flags; diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 8b86e45c..57e21d57 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -243,6 +243,27 @@ trait MethodCallTrait protected function parseParentMethodCall(Expr\StaticCall $expr): string { + // Inside a trait, `parent::` refers to the parent of the class that + // *uses* the trait. That parent class is only known at runtime (a single + // trait may be composed into classes with different parents), so resolve + // it dynamically from the current object's class entry instead of the + // trait's own (non-existent) parent. + if ($this->classDef !== null && $this->classDef->trait !== null) { + $method = $this->isIdExpr($expr->name) ? $this->parseIdentifier($expr->name) : ''; + // Record the parent:: call so it can be validated against the parent + // of every class that uses this trait (the trait itself has no parent + // at compile time). Dynamic method names cannot be validated statically. + if ($method !== '' && isset($this->methodDef)) { + $this->methodDef->parentMethodCalls[] = ['method' => $method, 'node' => $expr]; + } + $methodPtr = 'php::getMethod(this_.parent_ce(), ' . $this->identifierToStr($expr->name) . ')'; + if (empty($expr->args)) { + return 'this_.call(' . $methodPtr . ')'; + } + // Parent class is unknown statically, so by-ref argument detection is skipped. + return 'this_.call(' . $methodPtr . ', ' . $this->parseCallArgs($expr->args, $method, '') . ')'; + } + if (!$this->classDef->extends) { $this->fatalError($expr, 'Cannot call parent method because class `' . $this->classDef->name . '` does not extend any class'); } @@ -250,6 +271,11 @@ trait MethodCallTrait if ($this->isIdExpr($expr->name)) { $method = $this->parseIdentifier($expr->name); $this->guardAbstractMethod($parentClass, $method, $expr); + // A private parent method is not reachable via parent:: — PHP throws + // "Call to private method" at runtime, so report it at compile time. + if ($this->getMethodFlags($parentClass, $method) & Modifiers::PRIVATE) { + $this->fatalError($expr, "Cannot access private method `{$parentClass}::{$method}()` via parent::"); + } $methodPtr = $this->getMethodPtr($parentClass, $method); } else { $method = ''; diff --git a/src/Translator.php b/src/Translator.php index 676618a3..33d07f86 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3500,6 +3500,9 @@ CODE; if (!($flags & Modifiers::ABSTRACT)) { $this->methodDef = $this->classDef->getMethod($name); + // Keep the AST node so trait-composed methods can report accurate + // line numbers when validated for override compatibility later. + $this->methodDef->node = $v; // 预处理阶段没有父类的信息,只能在实现阶段检查 $this->checkParentMethodCanBeOverridden($v, $name); $methodCodes[$name] = $this->parseFunction($v); @@ -3610,6 +3613,23 @@ CODE; string $traitMethodName, string $classMethodName ): string { + // Validate `parent::` calls emitted from this trait method against the + // parent of the class that is composing the trait. The trait itself has + // no parent at compile time, so this is the only place the parent class + // (and therefore the visibility of its methods) is known. + foreach ($methodDef->parentMethodCalls as $parentCall) { + $this->validateTraitParentCall($traitDef, $classDef, $parentCall['method'], $parentCall['node']); + } + + // A trait method flattened into a class participates in the inheritance + // hierarchy: it must remain signature-compatible with any same-named + // parent method, exactly as a directly-declared override would. PHP + // enforces this at class declaration time ("Declaration of X::m() must + // be compatible with Y::m()"); without this check the incompatibility + // only surfaces as a runtime fatal error that the compiled binary would + // otherwise ignore and keep executing past. + $this->checkTraitMethodOverrideCompatibility($classDef, $methodDef, $classMethodName); + $classDef->addMethod($methodDef); $traitMethodNativeName = $this->getNativeName($traitMethodName, $traitDef->namespace, $traitDef->name); $classMethodNativeName = $this->getNativeName($classMethodName, $classDef->namespace, $classDef->name); @@ -3643,6 +3663,88 @@ CODE; return $code; } + /** + * Validate a `parent::method()` call recorded inside a trait method. + * + * The trait has no parent of its own, so the only point at which the parent + * class is known is when a class actually uses the trait. At that moment we + * can statically resolve the parent method and reject private methods, which + * PHP would otherwise only report as a runtime "Call to private method" error. + */ + private function validateTraitParentCall(ClassDef $traitDef, ClassDef $usingClassDef, string $method, NodeAbstract $node): void + { + if (!$usingClassDef->extends) { + // No parent class: cannot validate; PHP would report at runtime. + return; + } + $parentClass = $usingClassDef->extends; + // Internal / not-compiled parents are opaque to the compiler; let the + // runtime enforce visibility for those. + if (!$this->hasClass($parentClass)) { + return; + } + if ($this->getMethodFlags($parentClass, $method) & Modifiers::PRIVATE) { + $this->fatalError( + $node, + "Cannot access private method `{$parentClass}::{$method}()` via parent:: in trait `{$traitDef->name}`" + ); + } + } + + /** + * Validate that a trait method being flattened into a class remains + * signature-compatible with any same-named method declared up the parent + * chain — the same compatibility contract a directly-declared override must + * satisfy (see `checkParentMethodCanBeOverridden`). + * + * Only the signature contract is enforced here (not the "cannot override + * private/final" rule), because a trait method is flattened into the class + * and, like a normal subclass method, is allowed to shadow a private parent + * method. PHP reports the incompatibility as a class-declaration fatal error + * ("Declaration of X::m() must be compatible with Y::m()"), which we surface + * at compile time so the broken program is rejected instead of being emitted + * and executed past a runtime fatal error. + */ + private function checkTraitMethodOverrideCompatibility(ClassDef $usingClassDef, MethodDef $methodDef, string $methodName): void + { + if ($methodName === '__construct' || $methodDef->node === null) { + return; + } + $classDef = $usingClassDef; + while (true) { + $extends = $classDef->extends; + if (!$extends) { + break; + } + // Internal / not-compiled parents are opaque to the compiler; let the + // runtime enforce compatibility for those. + if ($classDef->inheritedFromInternalClass || !$this->hasClass($extends)) { + break; + } + $classDef = $this->getClass($extends); + if ($classDef->hasMethod($methodName)) { + $this->validateMethodOverrideSignature( + $methodDef->node, + $methodName, + $methodDef, + $classDef->getMethod($methodName), + $extends + ); + break; + } + if ($classDef->hasAbstractMethod($methodName) && isset($classDef->abstractMethodDefs[strtolower($methodName)])) { + $this->validateMethodOverrideSignature( + $methodDef->node, + $methodName, + $methodDef, + $classDef->getAbstractMethod($methodName), + $extends + ); + break; + } + } + } + private function isCompatibleTraitConstant(ConstantDef $existing, ConstantDef $incoming): bool { return $existing->flags === $incoming->flags && diff --git a/tests/compiler/trait/trait-parent-constructor.phpt b/tests/compiler/trait/trait-parent-constructor.phpt new file mode 100644 index 00000000..941968a6 --- /dev/null +++ b/tests/compiler/trait/trait-parent-constructor.phpt @@ -0,0 +1,36 @@ +--TEST-- +Trait constructor calling parent::__construct of the composing class +--FILE-- + +--EXPECT-- +int(123) +bool(true) diff --git a/tests/compiler/trait/trait-parent-method-protected.phpt b/tests/compiler/trait/trait-parent-method-protected.phpt new file mode 100644 index 00000000..661ab30c --- /dev/null +++ b/tests/compiler/trait/trait-parent-method-protected.phpt @@ -0,0 +1,34 @@ +--TEST-- +Trait method calling protected parent::method() of the composing class +--FILE-- +greet('World')); +} +?> +--EXPECT-- +string(23) "Hello World [via trait]" diff --git a/tests/compiler/trait/trait-parent-method.phpt b/tests/compiler/trait/trait-parent-method.phpt new file mode 100644 index 00000000..02e4839b --- /dev/null +++ b/tests/compiler/trait/trait-parent-method.phpt @@ -0,0 +1,34 @@ +--TEST-- +Trait method calling parent::method() of the composing class +--FILE-- +greet('World')); +} +?> +--EXPECT-- +string(23) "Hello World [via trait]" From 78039a28dbabd5ff57f7319cf15f2e828c39a8a9 Mon Sep 17 00:00:00 2001 From: Yurun Date: Sun, 19 Jul 2026 09:06:55 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix(compiler):=20=E4=BF=AE=E5=A4=8Dtrait?= =?UTF-8?q?=E6=96=B9=E6=B3=95self/static/parent=E7=B1=BB=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E7=BB=91=E5=AE=9A=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Entity/ArgInfo.php | 8 + src/Entity/FunctionDef.php | 9 ++ src/Preprocessor.php | 26 +++ src/Translator.php | 151 ++++++++++++++++++ .../trait/trait-method-parent-return.phpt | 32 ++++ .../trait-method-self-return-interface.phpt | 38 +++++ .../trait-method-static-return-interface.phpt | 35 ++++ 7 files changed, 299 insertions(+) create mode 100644 tests/compiler/trait/trait-method-parent-return.phpt create mode 100644 tests/compiler/trait/trait-method-self-return-interface.phpt create mode 100644 tests/compiler/trait/trait-method-static-return-interface.phpt diff --git a/src/Entity/ArgInfo.php b/src/Entity/ArgInfo.php index a7bac66c..76f3926f 100644 --- a/src/Entity/ArgInfo.php +++ b/src/Entity/ArgInfo.php @@ -22,6 +22,14 @@ class ArgInfo public ?Expr $defaultValue = null; public string $class = ''; + /** + * Late-bound type keyword: 'self', 'static' or 'parent'. + * Empty for ordinary class-name parameter types. When set, the effective + * class depends on the consuming context (e.g. a trait method's `self` + * parameter resolves to the class that uses the trait). + */ + public string $typeKeyword = ''; + /** * Object type declared in the PHP signature, including interfaces. * Unlike $class, this is only an assignment/type-check constraint and must diff --git a/src/Entity/FunctionDef.php b/src/Entity/FunctionDef.php index b4ec6058..0f37718c 100644 --- a/src/Entity/FunctionDef.php +++ b/src/Entity/FunctionDef.php @@ -40,6 +40,15 @@ class FunctionDef */ public string $returnClass = ''; + /** + * Late-bound return type keyword: 'self', 'static' or 'parent'. + * Empty for ordinary class-name return types. When set, the effective class + * depends on the consuming context (e.g. a trait method's `self` resolves to + * the class that uses the trait), so it must be re-resolved when the method + * is flattened into a class. + */ + public string $returnTypeKeyword = ''; + /** Same format as ArgInfo::$typeCheck. Null means no runtime return type check. */ public ?array $returnTypeCheck = null; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 0273d2cd..fd690242 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -268,6 +268,16 @@ class Preprocessor extends CompilerBase if ($param->byRef) { return Type::REF; } + // Capture the late-bound parameter type keyword *before* resolveTypeDecl + // runs, because resolveTypeDecl mutates the `self`/`static`/`parent` node + // name to the declaring class when the method belongs to a trait. + $typeKeyword = ''; + if ($param->type instanceof Node\Name) { + $ptLower = strtolower($param->type->toString()); + if ($ptLower === 'self' || $ptLower === 'static' || $ptLower === 'parent') { + $typeKeyword = $ptLower; + } + } [$type, $class] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); $argInfo->undeclared = $param->type === null; if ( @@ -284,6 +294,9 @@ class Preprocessor extends CompilerBase if ($class and !$this->hasInterface($class)) { $argInfo->class = $class; } + // Record late-bound parameter type keywords so they can be re-resolved + // to the consuming class when a trait method is flattened into a class. + $argInfo->typeKeyword = $typeKeyword; return $type; } @@ -432,6 +445,16 @@ class Preprocessor extends CompilerBase } $fnName = $this->parseIdentifier($v->name); + // Capture the late-bound return type keyword *before* resolveTypeDecl runs, + // because resolveTypeDecl mutates the `self`/`static`/`parent` node name to + // the declaring class when the method belongs to a trait. + $returnTypeKeyword = ''; + if ($v->returnType instanceof Node\Name) { + $rtLower = strtolower($v->returnType->toString()); + if ($rtLower === 'self' || $rtLower === 'static' || $rtLower === 'parent') { + $returnTypeKeyword = $rtLower; + } + } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); // 构造、析构、克隆方法不能有返回值 if ($this->method and in_array($this->method, ['__construct', '__destruct', '__clone'])) { @@ -440,6 +463,9 @@ class Preprocessor extends CompilerBase $functionDef = new FunctionDef($fnName, $returnType, $this->namespace); $functionDef->returnClass = $class; + // Record late-bound return type keywords so they can be re-resolved to + // the consuming class when a trait method is flattened into a class. + $functionDef->returnTypeKeyword = $returnTypeKeyword; $functionDef->stub = $this->stubFile; $functionDef->returnTypeUndeclared = $v->returnType === null; $functionDef->returnsByRef = $v->byRef; diff --git a/src/Translator.php b/src/Translator.php index 33d07f86..2a25518a 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -2354,6 +2354,13 @@ CODE; if ($traitStmt instanceof Node\Stmt\ClassMethod) { $methodName = strtolower($traitStmt->name->toString()); $fullMethodName = $this->getFullMethodName($traitFullName, $methodName); + // A trait method's `self`/`static`/`parent` return and parameter + // types refer to the class that uses the trait, not the trait + // itself. Re-resolve them on the cloned AST so the generated + // arginfo reflects the consuming class (PHP trait semantics) and + // passes ZendVM's runtime signature-compatibility checks. The + // alias clones below inherit this rewrite. + $this->reresolveTraitMethodAstLateBoundTypes($classDef, $traitFullName, $traitStmt); foreach ($classDef->traitAliases[$fullMethodName] ?? [] as $alias) { $aliasName = strtolower($alias['newName']); if ($aliasName === $methodName) { @@ -2472,6 +2479,62 @@ CODE; } } + /** + * Re-resolve a trait method's late-bound `self`/`static`/`parent` return and + * parameter types on the cloned AST that is being flattened into a class. + * + * `resolveTypeDecl()` mutates a trait method's `self`/`static`/`parent` type + * node to the trait's own name at parse time, so the cloned AST carries the + * trait name rather than the late-bound keyword. We instead rewrite those + * nodes to the consuming class (or its parent) using the keyword recorded on + * the trait method's FunctionDef, matching PHP's trait semantics. This keeps + * the generated arginfo correct for ZendVM's runtime compatibility checks. + */ + private function reresolveTraitMethodAstLateBoundTypes( + ClassDef $usingClassDef, + string $traitFullName, + Node\Stmt\ClassMethod $methodStmt + ): void { + if (!$this->hasClass($traitFullName)) { + return; + } + $traitDef = $this->getClass($traitFullName); + if (!$traitDef->hasMethod($methodStmt->name->toString())) { + return; + } + $fn = $traitDef->getMethod($methodStmt->name->toString())->functionDef; + + if ($fn->returnTypeKeyword !== '' && $methodStmt->returnType instanceof Node\Name) { + if ($fn->returnTypeKeyword === 'static') { + // `static` is late-static-bound: keep the keyword so ZendVM + // resolves it to the concrete class at call time. + $methodStmt->returnType = new Node\Name('static'); + } else { + $resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword); + if ($resolved !== null) { + $methodStmt->returnType = new Node\Name($resolved); + } + } + } + + foreach ($fn->argInfoList as $i => $arg) { + if ( + $arg->typeKeyword !== '' + && isset($methodStmt->params[$i]) + && $methodStmt->params[$i]->type instanceof Node\Name + ) { + if ($arg->typeKeyword === 'static') { + $methodStmt->params[$i]->type = new Node\Name('static'); + } else { + $resolved = $this->resolveLateBoundClass($usingClassDef, $arg->typeKeyword); + if ($resolved !== null) { + $methodStmt->params[$i]->type = new Node\Name($resolved); + } + } + } + } + } + /** * Validate that two abstract trait methods have compatible signatures. * PHP allows multiple traits to declare the same abstract method as long @@ -3613,6 +3676,14 @@ CODE; string $traitMethodName, string $classMethodName ): string { + // A trait method's `self`/`static`/`parent` return and parameter types + // refer to the class that uses the trait, not the trait itself. Re-resolve + // them to the consuming class so signature-compatibility checks (against + // parent classes and interfaces) and `detectClassOfExpr()` observe the + // correct type. The cloned FunctionDef keeps the trait's own native + // function untouched. + $this->reresolveTraitLateBoundTypes($classDef, $methodDef); + // Validate `parent::` calls emitted from this trait method against the // parent of the class that is composing the trait. The trait itself has // no parent at compile time, so this is the only place the parent class @@ -3663,6 +3734,86 @@ CODE; return $code; } + /** + * Re-resolve a trait method's late-bound `self`/`static`/`parent` return and + * parameter types to the class that is composing the trait. + * + * In PHP, `self` (and `static`) inside a trait refers to the using class, and + * `parent` refers to the using class's parent. The compiler records these as + * the trait's own name at parse time, which is wrong once the method is + * flattened into a class: interface/trait `self` comparisons and + * `detectClassOfExpr()` would otherwise observe the trait name instead of the + * consuming class. We clone the FunctionDef so the trait's standalone native + * function keeps its original (trait-context) types. + */ + private function reresolveTraitLateBoundTypes(ClassDef $usingClassDef, MethodDef $methodDef): void + { + $fn = $methodDef->functionDef; + $needsClone = false; + + if ($fn->returnTypeKeyword !== '') { + $resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword); + if ($resolved !== null && $resolved !== $fn->returnClass) { + $needsClone = true; + } + } + foreach ($fn->argInfoList as $arg) { + if ($arg->typeKeyword !== '') { + $resolved = $this->resolveLateBoundClass($usingClassDef, $arg->typeKeyword); + if ($resolved !== null && ($resolved !== $arg->class || $resolved !== $arg->declaredClass)) { + $needsClone = true; + break; + } + } + } + + if (!$needsClone) { + return; + } + + $newFn = clone $fn; + if ($fn->returnTypeKeyword !== '') { + $resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword); + if ($resolved !== null && $resolved !== $newFn->returnClass) { + $newFn->returnClass = $resolved; + } + } + $newArgs = []; + foreach ($newFn->argInfoList as $arg) { + $newArg = clone $arg; + if ($newArg->typeKeyword !== '') { + $resolved = $this->resolveLateBoundClass($usingClassDef, $newArg->typeKeyword); + if ($resolved !== null) { + if ($newArg->class !== '') { + $newArg->class = $resolved; + } + if ($newArg->declaredClass !== '') { + $newArg->declaredClass = $resolved; + } + } + } + $newArgs[] = $newArg; + } + $newFn->argInfoList = $newArgs; + $methodDef->functionDef = $newFn; + } + + private function resolveLateBoundClass(ClassDef $usingClassDef, string $keyword): ?string + { + if ($keyword === 'self') { + return $usingClassDef->getNamespacedName(false); + } + if ($keyword === 'parent') { + return $usingClassDef->extends !== '' ? $usingClassDef->extends : null; + } + // `static` is late-static-bound and resolved to the concrete class only at + // call time, so it must keep an empty class (matching a directly-declared + // `: static` method). Resolving it to the consuming class here would break + // interface/trait signature-compatibility checks, which compare the empty + // `static` class on both sides. + return null; + } + /** * Validate a `parent::method()` call recorded inside a trait method. * diff --git a/tests/compiler/trait/trait-method-parent-return.phpt b/tests/compiler/trait/trait-method-parent-return.phpt new file mode 100644 index 00000000..c1a9c8b3 --- /dev/null +++ b/tests/compiler/trait/trait-method-parent-return.phpt @@ -0,0 +1,32 @@ +--TEST-- +Trait method with `parent` return type flattened into a subclass +--FILE-- +who(); + var_dump($r instanceof Child); +} +?> +--EXPECT-- +bool(true) diff --git a/tests/compiler/trait/trait-method-self-return-interface.phpt b/tests/compiler/trait/trait-method-self-return-interface.phpt new file mode 100644 index 00000000..96545bec --- /dev/null +++ b/tests/compiler/trait/trait-method-self-return-interface.phpt @@ -0,0 +1,38 @@ +--TEST-- +Trait method with `self` return type flattened into a class that implements an interface declaring `self` return +--FILE-- +test(); + var_dump($result instanceof TestClass); + var_dump($result === $test); + var_dump($result instanceof TestInterface); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/tests/compiler/trait/trait-method-static-return-interface.phpt b/tests/compiler/trait/trait-method-static-return-interface.phpt new file mode 100644 index 00000000..9084c93d --- /dev/null +++ b/tests/compiler/trait/trait-method-static-return-interface.phpt @@ -0,0 +1,35 @@ +--TEST-- +Trait method with `static` return type flattened into a class that implements an interface declaring `static` return +--FILE-- +make(); + // `static` is late-static-bound to the consuming class (TestClass). + var_dump($b instanceof TestClass); + var_dump($b !== $a); +} +?> +--EXPECT-- +bool(true) +bool(true) From a1b09926a3831da3cc6463ec497e9c822be61676 Mon Sep 17 00:00:00 2001 From: Yurun Date: Sun, 19 Jul 2026 09:39:05 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix(compiler):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=B1=BB=E5=B8=B8=E9=87=8F=E7=BB=A7=E6=89=BF=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=8F=8A=E7=B1=BB=E5=9E=8B=E6=8E=A8=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 正确处理 parent::、self:: 和 static:: 的常量引用与类型解析 - 支持 ::class 魔法常量的编译期求值与字符串类型推断 - 引用其他类常量时继承其实际类型,避免误判为 VAR - 仅当父类常量有显式声明类型时才校验类型兼容性 - 运行时注册子类常量前先删除父类继承条目以避免重定义冲突 --- src/Entity/ConstantDef.php | 2 + src/Parser/ClassConstantFetchTrait.php | 11 +++ src/Preprocessor.php | 71 ++++++++++++++++++- src/Resolver/ClassConstantValueTrait.php | 10 +++ src/Translator.php | 26 +++++-- src/gen_stub.php | 41 +++++++---- .../const/class-const-override-variants.phpt | 51 +++++++++++++ .../compiler/const/class-const-override.phpt | 30 ++++++++ 8 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 tests/compiler/const/class-const-override-variants.phpt create mode 100644 tests/compiler/const/class-const-override.phpt diff --git a/src/Entity/ConstantDef.php b/src/Entity/ConstantDef.php index 6c192322..ed587ee9 100644 --- a/src/Entity/ConstantDef.php +++ b/src/Entity/ConstantDef.php @@ -19,6 +19,8 @@ class ConstantDef public string $arrayExpr = ''; public string $class = ''; public ?NodeAbstract $valueExpr = null; + /** Explicit declared type (e.g. `const int FOO`); null for inferred/untyped constants. */ + public ?string $declaredType = null; public function __construct(string $name, int $flags, string $type, string $value) { diff --git a/src/Parser/ClassConstantFetchTrait.php b/src/Parser/ClassConstantFetchTrait.php index 5d16ecb5..527a9c42 100644 --- a/src/Parser/ClassConstantFetchTrait.php +++ b/src/Parser/ClassConstantFetchTrait.php @@ -31,6 +31,17 @@ trait ClassConstantFetchTrait $self = true; $class = $this->class; } + } elseif ($class === 'parent') { + // `parent::` refers to the parent of the current class. Resolve it to + // the real parent class name and treat it like `self` for the purpose + // of constant/magic-class resolution. + $parentClass = $this->getParentClass($this->class); + if ($parentClass !== '' && $this->hasClass($parentClass)) { + $class = $this->getClass($parentClass)->name; + } else { + $class = $parentClass; + } + $self = true; } $const = $this->escapeString($this->parseIdentifier($expr->name)); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 0273d2cd..f2253b45 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -757,24 +757,89 @@ class Preprocessor extends CompilerBase 'Scalar_String' => Type::STR, default => Type::VAR, }; + // `::class` is a compile-time magic constant that always yields a string, + // so a constant declared as `X = self::class` (or `Foo::class`) must be + // typed as a string rather than a generic variant. + if ($type === Type::VAR + && $const->value instanceof Node\Expr\ClassConstFetch + && strtolower((string) $const->value->name) === 'class') { + $type = Type::STR; + } + // A constant whose value references another class constant + // (e.g. `X = ParentClass::Y` or `X = self::Y`) must take the referenced + // constant's type. This keeps override compatibility checks and the C++ + // declaration correct, mirroring PHP where overriding an untyped constant + // with a value of any (compatible) type is allowed. + if ($type === Type::VAR + && $const->value instanceof Node\Expr\ClassConstFetch + && $const->value->class instanceof Node\Name) { + $refType = $this->resolveReferencedConstantType($const->value, $this->getFullClassName()); + if ($refType !== null) { + $type = $refType; + } + } } $constName = $this->parseIdentifier($const->name); if ($this->classDef->hasConstant($constName)) { $this->fatalError($v, "Duplicate constant `{$constName}`"); } - $constInfo = $this->parseClassLikeConstant($const, $flags, $type, $class); + $constInfo = $this->parseClassLikeConstant($const, $flags, $type, $class, $declaredType); $constInfo->class = $class; $this->classDef->constants[$constInfo->name] = $constInfo; } } - private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $type, string $class = ''): ConstantDef + /** + * Resolve the compile-time type of a class constant whose value is a + * `ClassConstFetch` referencing another constant (e.g. `X = ParentClass::Y` + * or `X = self::Y`). Returns the referenced constant's type, or null when + * the reference cannot be resolved yet (for instance when the referenced + * class has not been prepared). `::class` always resolves to a string. + */ + private function resolveReferencedConstantType(Node\Expr\ClassConstFetch $fetch, string $currentClass): ?string + { + $constName = $fetch->name->toString(); + if (strcasecmp($constName, 'class') === 0) { + return Type::STR; + } + if (!($fetch->class instanceof Node\Name)) { + return null; + } + $className = $fetch->class->toString(); + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + $targetClass = $currentClass; + } elseif (strcasecmp($className, 'parent') === 0) { + $targetClass = $this->getParentClass($currentClass); + } else { + $targetClass = $this->getNamespacedClassName($className); + } + if ($targetClass === '' || !$this->hasClass($targetClass)) { + return null; + } + $def = $this->getClass($targetClass); + if (!$def->hasConstant($constName)) { + return null; + } + $refConst = $def->getConstant($constName); + // Follow the chain in case the referenced constant is itself an + // expression that resolves to another constant. + if ($refConst->type !== Type::VAR) { + return $refConst->type; + } + if ($refConst->valueExpr instanceof Node\Expr\ClassConstFetch) { + return $this->resolveReferencedConstantType($refConst->valueExpr, $targetClass); + } + return null; + } + + private function parseClassLikeConstant(Node\Const_ $const, int $flags, string $type, string $class = '', ?string $declaredType = null): ConstantDef { $constName = $this->parseIdentifier($const->name); $constValue = $this->parseIdentifier($const->value); $constInfo = new ConstantDef($constName, $flags, $type, $constValue); $constInfo->valueExpr = $const->value; + $constInfo->declaredType = $declaredType; if ($this->context->beforeStmtLines) { $arrayExpr = ''; @@ -979,7 +1044,7 @@ class Preprocessor extends CompilerBase default => Type::VAR, }; } - $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class); + $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class, $stmt->type ? $type : null); $this->interfaceDef->constants[$constName] = $constInfo; } continue; diff --git a/src/Resolver/ClassConstantValueTrait.php b/src/Resolver/ClassConstantValueTrait.php index 0853e993..a674fd7a 100644 --- a/src/Resolver/ClassConstantValueTrait.php +++ b/src/Resolver/ClassConstantValueTrait.php @@ -116,6 +116,16 @@ trait ClassConstantValueTrait if ($expr instanceof Node\Expr\ClassConstFetch && $expr->class instanceof Node\Name) { $constName = $expr->name->toString(); $className = $expr->class->toString(); + if (strcasecmp($constName, 'class') === 0) { + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name of the referenced class. + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + $className = $class; + } elseif (strcasecmp($className, 'parent') === 0) { + $className = $this->getParentClass($class); + } + return ltrim($this->getNamespacedClassName($className, $this->getNamespaceOfClass($class)), '\\'); + } if (strcasecmp($className, 'self') === 0) { $className = $class; } elseif (strcasecmp($className, 'parent') === 0) { diff --git a/src/Translator.php b/src/Translator.php index 676618a3..7b61f827 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3477,10 +3477,28 @@ CODE; if ($parentConst->flags & Modifiers::PRIVATE) { continue; } - if ($childConst->type !== $parentConst->type || $childConst->class !== $parentConst->class) { - $this->fatalError($classStmt, - "Declaration of `{$className}::{$name}` must be compatible " . - "with `{$parentClass}::{$name}`"); + // PHP only enforces type compatibility when the parent constant + // carries an explicit declared type. Overriding an untyped constant + // with a value of any type is permitted, so the type check is skipped + // in that case. Visibility is always enforced below. + if ($parentConst->declaredType !== null) { + // An untyped child constant whose value is an expression (e.g. + // `X = ParentClass::Y`) is inferred as a variant. Resolve its real + // type from the referenced constant so the compatibility check uses + // the actual value type. + $childType = $childConst->type; + if ($childType === Type::VAR + && $childConst->valueExpr instanceof Node\Expr\ClassConstFetch) { + $resolved = $this->resolveReferencedConstantType($childConst->valueExpr, $this->getFullClassName()); + if ($resolved !== null) { + $childType = $resolved; + } + } + if ($childType !== $parentConst->type || $childConst->class !== $parentConst->class) { + $this->fatalError($classStmt, + "Declaration of `{$className}::{$name}` must be compatible " . + "with `{$parentClass}::{$name}`"); + } } if ($this->getVisibilityRank($childConst->flags) < $this->getVisibilityRank($parentConst->flags)) { $this->fatalError($classStmt, diff --git a/src/gen_stub.php b/src/gen_stub.php index f266157f..752c6b9b 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -2354,18 +2354,29 @@ class EvaluatedValue } if ($expr instanceof Expr\ClassConstFetch) { + $constName = $expr->name->__toString(); + if (strcasecmp($constName, 'class') === 0) { + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name of the referenced class. + $className = getClassConstFetchClassName($expr); + if (strcasecmp($className, 'self') === 0 || strcasecmp($className, 'static') === 0) { + return ClassInfo::$currentClass; + } + if (strcasecmp($className, 'parent') === 0) { + return getTranslator()->getParentClass(ClassInfo::$currentClass); + } + return ltrim($className, '\\'); + } $class = getClassConstFetchClassName($expr); if ($class === 'self') { - $constName = ClassInfo::$currentClass . "::" . $expr->name->__toString(); + $constName = ClassInfo::$currentClass . "::" . $constName; if (isset($allConstInfos[$constName])) { return formatConstValue($allConstInfos[$constName]->getValue($allConstInfos)->value); } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $expr->name->toString())); + return formatConstValue(getTranslator()->getClassConstValue($expr, ClassInfo::$currentClass, $constName)); } - } elseif ($expr->name->__toString() === 'class') { - return $class; } else { - return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $expr->name->__toString(), ClassInfo::$currentClass)); + return formatConstValue(getTranslator()->getClassConstValue($expr, $class, $constName, ClassInfo::$currentClass)); } } else { $constName = $expr->name->__toString(); @@ -2490,16 +2501,14 @@ class EvaluatedValue // PHP single-quote to C double-quote string if ($this->type->isString()) { - if ( - $this->expr instanceof PhpParser\Node\Expr\ClassConstFetch - ) { - if ($this->expr->class instanceof PhpParser\Node\Name\FullyQualified and - $this->expr->name instanceof PhpParser\Node\Identifier and + if ($this->expr instanceof PhpParser\Node\Expr\ClassConstFetch) { + if ($this->expr->name instanceof PhpParser\Node\Identifier and $this->expr->name->__toString() === 'class') { - $expr = '"' . addcslashes($this->expr->class->name, '\\') . '"'; - } else { - return $this->value; + // `::class` is a compile-time magic constant that resolves to the + // fully qualified class name (already stored in $this->value). + return '"' . addcslashes($this->value, '\\') . '"'; } + return $this->value; } elseif ($this->expr instanceof Expr\ConstFetch) { return getTranslator()->getConstValue($this->expr->name->toString()); } elseif (!($this->expr instanceof String_)) { @@ -2899,6 +2908,12 @@ class ConstInfo extends VariableLike $code .= "\tzend_string *const_{$constName}_name = zend_string_init_interned(\"$constName\", sizeof(\"$constName\") - 1, true);\n"; $nameCode = "const_{$constName}_name"; + // A child class may override a constant inherited from its parent. The + // runtime copies the parent's constants into the child, so re-declaring + // the constant would fail with "Cannot redefine class constant". + // Drop any inherited entry first so the child's value replaces it. + $code .= "\tzend_hash_del(&class_entry->constants_table, $nameCode);\n"; + if ($this->exposedDocComment) { $commentCode = "const_{$constName}_comment"; $escapedCommentInit = $this->exposedDocComment->getInitCode(); diff --git a/tests/compiler/const/class-const-override-variants.phpt b/tests/compiler/const/class-const-override-variants.phpt new file mode 100644 index 00000000..32a085e4 --- /dev/null +++ b/tests/compiler/const/class-const-override-variants.phpt @@ -0,0 +1,51 @@ +--TEST-- +class const override variants (self::class, parent::class, references, multi-level) +--FILE-- + +--EXPECT-- +string(5) "hello" +string(3) "Mid" +string(4) "Base" +string(5) "other" +string(5) "hello" +string(4) "Leaf" +string(5) "hello" +int(42) +string(5) "hello" diff --git a/tests/compiler/const/class-const-override.phpt b/tests/compiler/const/class-const-override.phpt new file mode 100644 index 00000000..5d9e4c97 --- /dev/null +++ b/tests/compiler/const/class-const-override.phpt @@ -0,0 +1,30 @@ +--TEST-- +class const override referencing another constant +--FILE-- + +--EXPECT-- +string(1) "A" +string(1) "B" +string(1) "B" +string(3) "bbb" From 8b76f841adad95c52fb3dfb868a4812889098d87 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 10:49:57 +0800 Subject: [PATCH 09/15] test(compiler): strengthen undefined coalesce assignment coverage --- src/Parser/AssignOpTrait.php | 15 ++++++--------- .../coalesce/assign-coalesce-undefined-var.phpt | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 826cd011..dabef684 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -863,19 +863,16 @@ trait AssignOpTrait { $this->checkLeftValue($expr->var); - // PHP 允许对未定义的简单变量使用 ??=(例如 `$a ??= 123`): - // 此时 isset 为 false,直接执行赋值。需要提前声明该局部变量, - // 否则 isset 检查会因变量未定义而报错。此处必须声明为 Type::VAR - // (Variant),使其初值为 NULL,从而 isset 在运行时正确判定为 - // false 并执行赋值;若使用原生类型,isset 恒为 true 会导致取到 - // 未初始化的默认值(如 int(0)、空字符串)。 - if ($this->isVarExpr($expr->var) and !$this->hasVar($this->parseIdentifier($expr->var))) { - $this->addLocalVar($this->parseIdentifier($expr->var), Type::VAR); + // An undefined variable must exist before generating its isset check. + // Keep it as Variant so NULL remains distinguishable from native defaults. + $var = $this->isVarExpr($expr->var) ? $this->parseIdentifier($expr->var) : null; + if ($var !== null && !$this->hasVar($var)) { + $this->addLocalVar($var, Type::VAR); } $isset = $this->parseChainedExpr($expr->var, self::OP_ISSET); - $var = $this->parseWritableIdentifier($expr->var); + $var ??= $this->parseWritableIdentifier($expr->var); $propertyWriteTarget = $this->preparePropertyWriteTarget($expr->var); if ($propertyWriteTarget !== null) { diff --git a/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt b/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt index 4aad8edb..27dd886b 100644 --- a/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt +++ b/tests/compiler/coalesce/assign-coalesce-undefined-var.phpt @@ -8,7 +8,21 @@ var_dump($a); $b ??= 'foo'; $b ??= 'bar'; var_dump($b); + +$c ??= null; +var_dump(isset($c)); +$c ??= 'after-null'; +var_dump($c); + +for ($i = 0; $i < 2; $i++) { + $d ??= printf("default\n"); +} +var_dump($d); ?> --EXPECT-- int(123) string(3) "foo" +bool(false) +string(10) "after-null" +default +int(8) From e231aa2404f2b1eced506e825484c6fdb98abab4 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 10:58:13 +0800 Subject: [PATCH 10/15] test(compiler): cover cross-namespace parameter variance --- src/Translator.php | 5 +++-- .../interface-impl-param-type-cross-ns.phpt | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Translator.php b/src/Translator.php index ce370f1e..e7c3838a 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3242,6 +3242,7 @@ CODE; return $arg->typeCheck; } + $declaredClass = $arg->declaredClass ?: $arg->class; return match ($arg->type) { Type::INT => [['kind' => 'isInt']], Type::FLOAT => [['kind' => 'isFloat']], @@ -3249,8 +3250,8 @@ CODE; Type::STR => [['kind' => 'isString']], Type::ARRAY => [['kind' => 'isArray']], Type::RESOURCE => [['kind' => 'isResource']], - Type::OBJECT => $arg->declaredClass - ? [['kind' => 'instanceof', 'class' => $arg->declaredClass]] + Type::OBJECT => $declaredClass + ? [['kind' => 'instanceof', 'class' => $declaredClass]] : [['kind' => 'isObject']], default => null, }; diff --git a/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt b/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt index acaec9e6..535212a7 100644 --- a/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt +++ b/tests/compiler/namespace/interface-impl-param-type-cross-ns.phpt @@ -1,8 +1,9 @@ --TEST-- -Cross-namespace interface implementation with an interface-typed parameter must not be reported as incompatible +Cross-namespace interface implementation parameter compatibility is declaration-order independent --FILE-- test(new \B\Impl1())); + var_dump($obj->testParent(new \B\Impl1())); echo "done\n"; } } ?> --EXPECT-- bool(true) +bool(true) done From 6162185bd76c283c2a669370d24949e19c7fd5cc Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 11:04:28 +0800 Subject: [PATCH 11/15] fix(compiler): scope interface self type resolution --- src/CompilerBase.php | 11 ++++++---- src/Generator/TypeCheckGenerator.php | 2 +- src/Resolver/NameResolutionTrait.php | 2 +- .../interface-self-return-namespaced.phpt | 20 +++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/CompilerBase.php b/src/CompilerBase.php index fab95bf8..310a79b7 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1017,10 +1017,13 @@ class CompilerBase implements PropertyAccessContext protected function getFullClassName(): string { - // 在接口上下文中,$this->class 为空但 $this->interface 已设置, - // `self` 类型声明应解析为接口自身的完整名称。 - $classLike = $this->class !== '' ? $this->class : $this->interface; - return ltrim($this->namespace . '\\' . $classLike, '\\'); + return ltrim($this->namespace . '\\' . $this->class, '\\'); + } + + protected function getFullClassLikeName(): string + { + $name = $this->class !== '' ? $this->class : $this->interface; + return ltrim($this->namespace . '\\' . $name, '\\'); } protected function getFullMethodName(string $fullClassName, string $method): string diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index 16dc4ae3..292967a1 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -104,7 +104,7 @@ trait TypeCheckGenerator } if ($name === 'self') { - $class = $this->getFullClassName(); + $class = $this->getFullClassLikeName(); } elseif ($name === 'parent') { $class = $this->classDef->extends ?? ''; } elseif ($name === 'static') { diff --git a/src/Resolver/NameResolutionTrait.php b/src/Resolver/NameResolutionTrait.php index 307117e5..827db4a3 100644 --- a/src/Resolver/NameResolutionTrait.php +++ b/src/Resolver/NameResolutionTrait.php @@ -166,7 +166,7 @@ trait NameResolutionTrait return $this->getTypeFromZendType($typeNameLower); } else { if ($typeName === 'self') { - $class = $this->getFullClassName(); + $class = $this->getFullClassLikeName(); } elseif ($typeName === 'parent') { if (!$this->classDef) { $this->fatalError($type, 'Cannot use "parent" type declaration outside a class'); diff --git a/tests/compiler/namespace/interface-self-return-namespaced.phpt b/tests/compiler/namespace/interface-self-return-namespaced.phpt index cfda0bad..4d05a98a 100644 --- a/tests/compiler/namespace/interface-self-return-namespaced.phpt +++ b/tests/compiler/namespace/interface-self-return-namespaced.phpt @@ -7,6 +7,10 @@ namespace App { interface Chainable { public function chain(): self; + + public function maybe(bool $present): ?self; + + public function combine(self $other): self; } // comment inside a named namespace block (Stmt_Nop) @@ -19,6 +23,16 @@ namespace App { $this->log[] = 'chain'; return $this; } + + public function maybe(bool $present): ?self + { + return $present ? $this : null; + } + + public function combine(Chainable $other): self + { + return $this; + } } } @@ -28,9 +42,15 @@ namespace { $w = new \App\Widget(); var_dump($w->chain()->chain() instanceof \App\Chainable); var_dump(count($w->log)); + var_dump($w->maybe(true) instanceof \App\Chainable); + var_dump($w->maybe(false)); + var_dump($w->combine(new \App\Widget()) === $w); } } ?> --EXPECT-- bool(true) int(2) +bool(true) +NULL +bool(true) From 848c763c0dfa63ea33a4207074377fa56c23cec6 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 11:09:22 +0800 Subject: [PATCH 12/15] test(compiler): cover expression variable names --- phpunit/code/variable-variable-function-call.php | 12 ++++++++++++ phpunit/src/VariableVariableTest.php | 5 +++++ src/CompilerBase.php | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 phpunit/code/variable-variable-function-call.php diff --git a/phpunit/code/variable-variable-function-call.php b/phpunit/code/variable-variable-function-call.php new file mode 100644 index 00000000..26fd0ef1 --- /dev/null +++ b/phpunit/code/variable-variable-function-call.php @@ -0,0 +1,12 @@ +exec('The `$$` syntax is not supported', 'variable-variable-arraydim.php'); } + + public function testVariableVariableWithFunctionCallThrowsUnsupportedError(): void + { + $this->exec('The `$$` syntax is not supported', 'variable-variable-function-call.php'); + } } diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 6381503d..eff7eab5 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1273,7 +1273,7 @@ class CompilerBase implements PropertyAccessContext protected function parseVariable(Variable $expr): string { - if (is_object($expr->name)) { + if (!is_string($expr->name)) { $this->fatalError($expr, 'The `$$` syntax is not supported'); } if ($this->isSuperGlobal($expr->name)) { From 1128fb06c822daa7e0a1d24ad8556e35a36bb949 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 11:15:56 +0800 Subject: [PATCH 13/15] fix(compiler): preserve ref snapshot evaluation order --- src/CompilerBase.php | 17 +++------- .../dynamic-return-reference-argument.phpt | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 5888454c..e92ffb80 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -1067,23 +1067,16 @@ class CompilerBase implements PropertyAccessContext } /** - * 把一个"按引用返回"的调用在按值消费处解引用为值快照。 - * - * 返回引用的调用会产生一个指向被调用方存储的活引用。当该调用被按值消费 - * (例如作为按值函数参数、数组元素、按值返回等会触发 PHP 分离语义的上下文) - * 时,PHP 会在求值那一刻拷贝出值的快照,因此之后对别名存储的修改不应再可见。 - * 我们通过把结果赋值给一个临时 php::Var 来分离引用(普通的 Var 赋值会断开 - * 引用,而 php::Variant(php::Ref) 构造会保留引用),从而保留从左到右的求值顺序。 - * - * 注意:二元/一元运算等操作数上下文应保持引用活动、在运算时读值,不应在此快照; - * 那些上下文由各自的解析器直接保留引用。 + * Snapshot a reference-returning call before a by-value container can retain + * its php::Ref. Assigning to an existing Var detaches the reference, unlike + * constructing a Variant directly from Ref. Keep the assignment inline so + * earlier arguments or array elements retain PHP's evaluation order. */ protected function materializeRefReturnAsValue(NodeAbstract $value, string $expr): string { if ($value instanceof Expr\CallLike && $this->resolveRefReturningCall($value) !== false) { $tmpVar = $this->addTmpVar(Type::VAR); - $this->context->beforeStmtLines[] = $tmpVar . ' = ' . $expr . ';'; - return $tmpVar; + return '(' . $tmpVar . ' = ' . $expr . ')'; } return $expr; } diff --git a/tests/compiler/ref/dynamic-return-reference-argument.phpt b/tests/compiler/ref/dynamic-return-reference-argument.phpt index 471881ef..aede4d2a 100644 --- a/tests/compiler/ref/dynamic-return-reference-argument.phpt +++ b/tests/compiler/ref/dynamic-return-reference-argument.phpt @@ -14,6 +14,9 @@ function main() var_dump($v1, $v2); var_dump(test1(), test2()); var_dump([test1(), test2()]); + var_dump(['first' => test1(), test2()]); + var_dump(value_order('arg-left'), ref_order('arg-ref')); + var_dump([value_order('array-left'), ref_order('array-ref')]); } function &test1() @@ -28,6 +31,19 @@ function &test2() ++$value; return $value; } + +function value_order(string $label): string +{ + echo "$label\n"; + return $label; +} + +function &ref_order(string $label) +{ + static $value = 42; + echo "$label\n"; + return $value; +} ?> --EXPECT-- int(1) @@ -44,3 +60,21 @@ array(2) { [1]=> int(4) } +array(2) { + ["first"]=> + int(5) + [0]=> + int(6) +} +arg-left +arg-ref +string(8) "arg-left" +int(42) +array-left +array-ref +array(2) { + [0]=> + string(10) "array-left" + [1]=> + int(42) +} From c75b4d565bac89551ce40375566552ad91fbc01f Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 11:24:03 +0800 Subject: [PATCH 14/15] fix(compiler): enforce class constant override rules --- .../code/inheritance_error_const_final.php | 13 ++++++ .../inheritance_error_const_missing_type.php | 13 ++++++ phpunit/src/InheritanceErrorTest.php | 10 +++++ src/Parser/ClassConstantFetchTrait.php | 14 +++--- src/Translator.php | 9 ++++ .../const/class-const-parent-cross-ns.phpt | 43 +++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 phpunit/code/inheritance_error_const_final.php create mode 100644 phpunit/code/inheritance_error_const_missing_type.php create mode 100644 tests/compiler/const/class-const-parent-cross-ns.phpt diff --git a/phpunit/code/inheritance_error_const_final.php b/phpunit/code/inheritance_error_const_final.php new file mode 100644 index 00000000..e2e57cc7 --- /dev/null +++ b/phpunit/code/inheritance_error_const_final.php @@ -0,0 +1,13 @@ +exec('must be compatible', 'inheritance_error_const_type.php'); } + public function testTypedConstantCannotBeOverriddenWithoutDeclaredType() + { + $this->exec('must be compatible', 'inheritance_error_const_missing_type.php'); + } + + public function testFinalConstantCannotBeOverridden() + { + $this->exec('Cannot override final constant', 'inheritance_error_const_final.php'); + } + public function testConstantVisibilityMismatch() { $this->exec('must be compatible', 'inheritance_error_const_visibility.php'); diff --git a/src/Parser/ClassConstantFetchTrait.php b/src/Parser/ClassConstantFetchTrait.php index 527a9c42..d5b01a94 100644 --- a/src/Parser/ClassConstantFetchTrait.php +++ b/src/Parser/ClassConstantFetchTrait.php @@ -32,15 +32,12 @@ trait ClassConstantFetchTrait $class = $this->class; } } elseif ($class === 'parent') { - // `parent::` refers to the parent of the current class. Resolve it to - // the real parent class name and treat it like `self` for the purpose - // of constant/magic-class resolution. - $parentClass = $this->getParentClass($this->class); - if ($parentClass !== '' && $this->hasClass($parentClass)) { - $class = $this->getClass($parentClass)->name; - } else { - $class = $parentClass; + if (!$this->classDef || !$this->classDef->extends) { + $this->fatalError($expr, 'Cannot use "parent" outside a class or class does not extend any class'); } + // extends is already fully resolved. Keep the leading slash so the + // current namespace is not applied again below. + $class = '\\' . $this->classDef->extends; $self = true; } @@ -109,4 +106,3 @@ trait ClassConstantFetchTrait } } - diff --git a/src/Translator.php b/src/Translator.php index 7b61f827..70d063a9 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3477,11 +3477,20 @@ CODE; if ($parentConst->flags & Modifiers::PRIVATE) { continue; } + if ($parentConst->flags & Modifiers::FINAL) { + $this->fatalError($classStmt, + "Cannot override final constant `{$parentClass}::{$name}`"); + } // PHP only enforces type compatibility when the parent constant // carries an explicit declared type. Overriding an untyped constant // with a value of any type is permitted, so the type check is skipped // in that case. Visibility is always enforced below. if ($parentConst->declaredType !== null) { + if ($childConst->declaredType === null) { + $this->fatalError($classStmt, + "Declaration of `{$className}::{$name}` must be compatible " . + "with `{$parentClass}::{$name}`"); + } // An untyped child constant whose value is an expression (e.g. // `X = ParentClass::Y`) is inferred as a variant. Resolve its real // type from the referenced constant so the compatibility check uses diff --git a/tests/compiler/const/class-const-parent-cross-ns.phpt b/tests/compiler/const/class-const-parent-cross-ns.phpt new file mode 100644 index 00000000..f550fb66 --- /dev/null +++ b/tests/compiler/const/class-const-parent-cross-ns.phpt @@ -0,0 +1,43 @@ +--TEST-- +parent class constants resolve across namespaces +--FILE-- + +--EXPECT-- +string(12) "Library\Base" +string(19) "Application\Sibling" +string(12) "Library\Base" +string(4) "base" From f34fe1844d890053711096c1d1a4a5381b8a9825 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Sun, 19 Jul 2026 12:28:31 +0800 Subject: [PATCH 15/15] fix(compiler): preserve trait parent scope --- phpunit/code/trait-method-override-final.php | 24 +++++++ phpunit/code/trait-method-shadows-private.php | 27 ++++++++ phpunit/code/trait-parent-without-parent.php | 18 ++++++ phpunit/src/ClassTest.php | 5 ++ phpunit/src/InheritanceErrorTest.php | 10 +++ src/Generator/CallArgumentGenerator.php | 21 +++++-- src/Parser/MethodCallTrait.php | 22 ++++--- src/Translator.php | 63 +++++++++++++++---- .../trait/trait-parent-method-inherited.phpt | 52 +++++++++++++++ .../trait/trait-parent-method-reference.phpt | 36 +++++++++++ 10 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 phpunit/code/trait-method-override-final.php create mode 100644 phpunit/code/trait-method-shadows-private.php create mode 100644 phpunit/code/trait-parent-without-parent.php create mode 100644 tests/compiler/trait/trait-parent-method-inherited.phpt create mode 100644 tests/compiler/trait/trait-parent-method-reference.phpt diff --git a/phpunit/code/trait-method-override-final.php b/phpunit/code/trait-method-override-final.php new file mode 100644 index 00000000..2cf6beeb --- /dev/null +++ b/phpunit/code/trait-method-override-final.php @@ -0,0 +1,24 @@ +execute('ok')); +} diff --git a/phpunit/code/trait-parent-without-parent.php b/phpunit/code/trait-parent-without-parent.php new file mode 100644 index 00000000..c3a025d2 --- /dev/null +++ b/phpunit/code/trait-parent-without-parent.php @@ -0,0 +1,18 @@ +exec('Cannot access private method `BaseSecret::secret()`', 'trait-parent-method-private.php'); } + public function testTraitMethodMayShadowPrivateParentMethod() + { + $this->compile('trait-method-shadows-private.php'); + } + public function testSelfCanBePartOfUnionType() { global $translator; diff --git a/phpunit/src/InheritanceErrorTest.php b/phpunit/src/InheritanceErrorTest.php index d7ad5cbf..59c07e14 100644 --- a/phpunit/src/InheritanceErrorTest.php +++ b/phpunit/src/InheritanceErrorTest.php @@ -325,4 +325,14 @@ class InheritanceErrorTest extends TestCase // runtime fatal error that the compiled binary would otherwise ignore. $this->exec('must be compatible', 'trait-method-override-incompatible.php'); } + + public function testTraitMethodCannotOverrideFinalParentMethod() + { + $this->exec('Cannot override final method', 'trait-method-override-final.php'); + } + + public function testTraitParentCallRequiresParentClass() + { + $this->exec('has no parent', 'trait-parent-without-parent.php'); + } } diff --git a/src/Generator/CallArgumentGenerator.php b/src/Generator/CallArgumentGenerator.php index 79431f24..826516eb 100644 --- a/src/Generator/CallArgumentGenerator.php +++ b/src/Generator/CallArgumentGenerator.php @@ -348,7 +348,8 @@ trait CallArgumentGenerator string $funcName = '', string $className = '', bool $separateNamedArgs = true, - bool $forceArrayArgs = false + bool $forceArrayArgs = false, + bool $preserveExistingReferences = false ): string { $list_args = []; @@ -390,7 +391,8 @@ trait CallArgumentGenerator $this->fatalError($arg, "Duplicate named argument `{$arg->name->name}`"); } $namedArgs[$arg->name->name] = true; - $byRef = $funcName && $this->isReferenceNamedArgument($funcName, $className, $arg->name->name); + $byRef = ($funcName && $this->isReferenceNamedArgument($funcName, $className, $arg->name->name)) + || ($preserveExistingReferences && $this->isExistingReferenceCallArg($arg)); $value = ($byRef || $this->isRefvalCall($arg->value) || $this->isToRefCall($arg->value)) ? $this->parseReferenceCallArgValue($arg) : $this->parseCallArgValue($arg); @@ -409,7 +411,8 @@ trait CallArgumentGenerator if ($hasUnpack) { $this->fatalError($arg, 'Cannot use positional argument after argument unpacking'); } - $byRef = $funcName && $this->isReferenceArgument($funcName, $className, $i); + $byRef = ($funcName && $this->isReferenceArgument($funcName, $className, $i)) + || ($preserveExistingReferences && $this->isExistingReferenceCallArg($arg)); if (($funcName === 'call_user_func' || $funcName === 'call_user_func_array') && $i === 0) { $callback = $this->parseScopedCallbackArg($arg); if ($callback !== null) { @@ -499,6 +502,15 @@ trait CallArgumentGenerator return $namedArgsVar !== null ? $callArgs . ', ' . $namedArgsVar . '.array()' : $callArgs; } + private function isExistingReferenceCallArg(Node\Arg $arg): bool + { + if (!$this->isVarExpr($arg->value)) { + return $this->isReferenceWrapperCall($arg->value); + } + $name = $this->parseIdentifier($arg->value); + return $this->hasVar($name) && $this->getVarType($name) === Type::REF; + } + protected function parseScopedCallbackArg(Node\Arg $arg): ?string { $value = $arg->value; @@ -716,6 +728,8 @@ trait CallArgumentGenerator if (!$this->hasVar($name)) { // 若参数是引用类型,可以传入未定义变量,将立即创建变量作为引用 $this->addLocalVar($name, Type::REF); + } elseif ($this->getVarType($name) === Type::REF) { + return '&' . $name; } else { // 本地变量,且是原生类型,则转为普通变量 if ($this->hasLocalVar($name) and $this->isNativeType($this->getVarType($name))) { @@ -788,4 +802,3 @@ trait CallArgumentGenerator } } - diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 57e21d57..6ede7f2c 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -243,11 +243,10 @@ trait MethodCallTrait protected function parseParentMethodCall(Expr\StaticCall $expr): string { - // Inside a trait, `parent::` refers to the parent of the class that - // *uses* the trait. That parent class is only known at runtime (a single - // trait may be composed into classes with different parents), so resolve - // it dynamically from the current object's class entry instead of the - // trait's own (non-existent) parent. + // A trait's parent scope is supplied by the wrapper generated for the + // class that composes it. It must not be derived from the runtime + // object's class: an inherited trait method is still lexically bound to + // the parent of the composing class, not to the runtime object's parent. if ($this->classDef !== null && $this->classDef->trait !== null) { $method = $this->isIdExpr($expr->name) ? $this->parseIdentifier($expr->name) : ''; // Record the parent:: call so it can be validated against the parent @@ -256,12 +255,19 @@ trait MethodCallTrait if ($method !== '' && isset($this->methodDef)) { $this->methodDef->parentMethodCalls[] = ['method' => $method, 'node' => $expr]; } - $methodPtr = 'php::getMethod(this_.parent_ce(), ' . $this->identifierToStr($expr->name) . ')'; + $methodPtr = 'php::getMethod(trait_parent_ce, ' . $this->identifierToStr($expr->name) . ')'; if (empty($expr->args)) { return 'this_.call(' . $methodPtr . ')'; } - // Parent class is unknown statically, so by-ref argument detection is skipped. - return 'this_.call(' . $methodPtr . ', ' . $this->parseCallArgs($expr->args, $method, '') . ')'; + // The concrete parent signature is only known at each trait use + // site. Preserve arguments that are already references so forwarding + // a by-reference trait parameter does not silently drop its alias. + return 'this_.call(' . $methodPtr . ', ' . $this->parseCallArgs( + $expr->args, + $method, + '', + preserveExistingReferences: true + ) . ')'; } if (!$this->classDef->extends) { diff --git a/src/Translator.php b/src/Translator.php index 8ddf5cde..1f15eca3 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -2506,8 +2506,7 @@ CODE; if ($fn->returnTypeKeyword !== '' && $methodStmt->returnType instanceof Node\Name) { if ($fn->returnTypeKeyword === 'static') { - // `static` is late-static-bound: keep the keyword so ZendVM - // resolves it to the concrete class at call time. + // `static` remains late-bound in the composed method signature. $methodStmt->returnType = new Node\Name('static'); } else { $resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword); @@ -2734,7 +2733,12 @@ CODE; return $code; } - protected function genWrapperFunctionArgs(string $fn, FunctionDef $functionDef, string $displayName): string + protected function genWrapperFunctionArgs( + string $fn, + FunctionDef $functionDef, + string $displayName, + array $implicitMethodArgs = [] + ): string { $cppCode = ''; $callParams = ''; @@ -2783,7 +2787,8 @@ CODE; } if ($functionDef->method) { - $callParams = $functionDef->argInfoList ? 'this_, ' . rtrim($callParams, ',') : 'this_'; + $methodArgs = implode(', ', array_merge(['this_'], $implicitMethodArgs)); + $callParams = $functionDef->argInfoList ? $methodArgs . ', ' . rtrim($callParams, ',') : $methodArgs; } else { $callParams = $functionDef->argInfoList ? rtrim($callParams, ',') : ''; } @@ -2844,7 +2849,18 @@ CODE; $cppCode = 'ZEND_METHOD(' . $name . ', ' . $methodDef->name . '){' . PHP_EOL; $cppCode .= $this->getIndent() . Type::OBJECT . ' this_(&execute_data->This);' . PHP_EOL; $fn = self::PREFIX . $this->getNativeMethodName($classDef, $methodDef); - $cppCode .= $this->genWrapperFunctionArgs($fn, $methodDef->functionDef, $classDef->getNamespacedName(false) . '::' . $methodDef->name); + $implicitMethodArgs = []; + if ($classDef->trait !== null && $methodDef->parentMethodCalls) { + // Trait methods are not directly callable without a composing class, + // but keep the generated Zend wrapper well-formed. + $implicitMethodArgs[] = 'this_.parent_ce()'; + } + $cppCode .= $this->genWrapperFunctionArgs( + $fn, + $methodDef->functionDef, + $classDef->getNamespacedName(false) . '::' . $methodDef->name, + $implicitMethodArgs + ); return $cppCode; } @@ -3041,6 +3057,9 @@ CODE; $functionDeclCode = $cppReturnType . ' ' . ($multiReturn ? $this->getMultiReturnImplName($name) : $nativeName) . '('; if ($this->class) { $functionDeclCode .= Type::OBJECT . ' &this_'; + if ($this->classDef?->trait !== null && $this->methodDef?->parentMethodCalls) { + $functionDeclCode .= ', zend_class_entry *trait_parent_ce'; + } if ($this->functionDef->params) { $functionDeclCode .= ', '; } @@ -3733,6 +3752,11 @@ CODE; $traitMethodNativeName = $this->getNativeName($traitMethodName, $traitDef->namespace, $traitDef->name); $classMethodNativeName = $this->getNativeName($classMethodName, $classDef->namespace, $classDef->name); $argList = ['this_']; + if ($methodDef->parentMethodCalls) { + // Bind parent:: to the class that actually composes the trait. This + // remains correct when the generated wrapper is inherited further. + $argList[] = $this->getClassEntryPtr($classDef->extends); + } foreach ($methodDef->functionDef->argInfoList as $argInfo) { $argList[] = $argInfo->name; } @@ -3853,8 +3877,10 @@ CODE; private function validateTraitParentCall(ClassDef $traitDef, ClassDef $usingClassDef, string $method, NodeAbstract $node): void { if (!$usingClassDef->extends) { - // No parent class: cannot validate; PHP would report at runtime. - return; + $this->fatalError( + $node, + "Cannot access parent when class `{$usingClassDef->getNamespacedName(false)}` has no parent" + ); } $parentClass = $usingClassDef->extends; // Internal / not-compiled parents are opaque to the compiler; let the @@ -3895,18 +3921,33 @@ CODE; if (!$extends) { break; } - // Internal / not-compiled parents are opaque to the compiler; let the - // runtime enforce compatibility for those. - if ($classDef->inheritedFromInternalClass || !$this->hasClass($extends)) { + if ($classDef->inheritedFromInternalClass) { + $modifiers = Reflection::getClassMethodModifiers($extends, $methodName); + if ($modifiers !== null && ($modifiers & \ReflectionMethod::IS_FINAL)) { + $this->fatalError($methodDef->node, "Cannot override final method `{$extends}::{$methodName}()`"); + } + break; + } + // Dynamically supplied parents are opaque to the compiler. + if (!$this->hasClass($extends)) { break; } $classDef = $this->getClass($extends); if ($classDef->hasMethod($methodName)) { + $parentMethodDef = $classDef->getMethod($methodName); + // A private method is a separate slot and may be shadowed by the + // method imported from the trait. + if ($parentMethodDef->flags & Modifiers::PRIVATE) { + break; + } + if ($parentMethodDef->flags & Modifiers::FINAL) { + $this->fatalError($methodDef->node, "Cannot override final method `{$extends}::{$methodName}()`"); + } $this->validateMethodOverrideSignature( $methodDef->node, $methodName, $methodDef, - $classDef->getMethod($methodName), + $parentMethodDef, $extends ); break; diff --git a/tests/compiler/trait/trait-parent-method-inherited.phpt b/tests/compiler/trait/trait-parent-method-inherited.phpt new file mode 100644 index 00000000..c6f5a752 --- /dev/null +++ b/tests/compiler/trait/trait-parent-method-inherited.phpt @@ -0,0 +1,52 @@ +--TEST-- +Trait parent:: call remains bound to the composing class when inherited +--FILE-- +source()); + var_dump((new OtherTraitUser())->source()); +} +?> +--EXPECT-- +string(4) "root" +string(5) "other" diff --git a/tests/compiler/trait/trait-parent-method-reference.phpt b/tests/compiler/trait/trait-parent-method-reference.phpt new file mode 100644 index 00000000..4e72026d --- /dev/null +++ b/tests/compiler/trait/trait-parent-method-reference.phpt @@ -0,0 +1,36 @@ +--TEST-- +Trait parent:: call forwards an existing reference parameter +--FILE-- +update($value); + var_dump($value); +} +?> +--EXPECT-- +string(7) "updated"