From 7d698987d705c1c1dcc04d2f783fb7f266e68ee4 Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Mon, 31 Aug 2026 20:01:12 +0800 Subject: [PATCH] fix(trait): preserve late-bound abstract type semantics --- ...rait_abstract_explicit_trait_parameter.php | 14 + .../trait_abstract_explicit_trait_return.php | 17 ++ ...t_abstract_explicit_trait_union_return.php | 17 ++ ...ait_abstract_nested_parent_unavailable.php | 27 ++ phpunit/code/trait_abstract_parent_return.php | 19 ++ .../trait_abstract_parent_union_param.php | 16 + phpunit/src/TraitAbstractRequirementTest.php | 48 +++ src/Entity/ArgInfo.php | 3 +- src/Generator/TypeCheckGenerator.php | 47 ++- src/Preprocessor.php | 2 + src/Translator.php | 281 ++++++++++++++---- .../trait-abstract-late-bound-types.phpt | 112 +++++++ 12 files changed, 537 insertions(+), 66 deletions(-) create mode 100644 phpunit/code/trait_abstract_explicit_trait_parameter.php create mode 100644 phpunit/code/trait_abstract_explicit_trait_return.php create mode 100644 phpunit/code/trait_abstract_explicit_trait_union_return.php create mode 100644 phpunit/code/trait_abstract_nested_parent_unavailable.php create mode 100644 phpunit/code/trait_abstract_parent_return.php create mode 100644 phpunit/code/trait_abstract_parent_union_param.php create mode 100644 tests/compiler/trait/trait-abstract-late-bound-types.phpt diff --git a/phpunit/code/trait_abstract_explicit_trait_parameter.php b/phpunit/code/trait_abstract_explicit_trait_parameter.php new file mode 100644 index 00000000..2c86fb48 --- /dev/null +++ b/phpunit/code/trait_abstract_explicit_trait_parameter.php @@ -0,0 +1,14 @@ +compile('trait_abstract_variance_ok.php'); } + + public function testExplicitTraitReturnIsNotTreatedAsSelf(): void + { + $this->exec( + 'Declaration of `InvalidExplicitTraitReturn::make()` must be compatible with `ExplicitTraitReturnRequirement::make()`', + 'trait_abstract_explicit_trait_return.php' + ); + } + + public function testExplicitTraitTypeInUnionIsNotTreatedAsSelf(): void + { + $this->exec( + 'Declaration of `InvalidExplicitTraitUnionReturn::make()` must be compatible with `ExplicitTraitUnionRequirement::make()`', + 'trait_abstract_explicit_trait_union_return.php' + ); + } + + public function testExplicitTraitParameterIsNotTreatedAsSelf(): void + { + $this->exec( + 'Declaration of `InvalidExplicitTraitParameter::accept()` must be compatible with `ExplicitTraitParameterRequirement::accept()`', + 'trait_abstract_explicit_trait_parameter.php' + ); + } + + public function testParentReturnUsesTheConsumingClassParent(): void + { + $this->exec( + 'Declaration of `InvalidParentReturnImplementation::make()` must be compatible with `ParentReturnRequirement::make()`', + 'trait_abstract_parent_return.php' + ); + } + + public function testParentUnionParameterUsesTheConsumingClassParent(): void + { + $this->exec( + 'Declaration of `InvalidParentParamImplementation::accept()` must be compatible with `ParentParamRequirement::accept()`', + 'trait_abstract_parent_union_param.php' + ); + } + + public function testNestedTraitCannotResolveParentFromAnEventualConsumer(): void + { + $this->exec( + 'Cannot use "parent" when current class scope has no parent', + 'trait_abstract_nested_parent_unavailable.php' + ); + } } diff --git a/src/Entity/ArgInfo.php b/src/Entity/ArgInfo.php index b7c17f70..ac6d3947 100644 --- a/src/Entity/ArgInfo.php +++ b/src/Entity/ArgInfo.php @@ -48,7 +48,8 @@ class ArgInfo public bool $immutable = false; /** - * Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => ''] + * Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => '', + * optional 'lateBound' => 'self'|'static'|'parent']. * Null means no runtime type check needed. */ public ?array $typeCheck = null; diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index c99fbd62..87a9d897 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -19,6 +19,31 @@ use PhpParser\NodeAbstract; trait TypeCheckGenerator { + protected const string LATE_BOUND_TYPE_ATTRIBUTE = 'typephp_late_bound_type'; + + protected function markLateBoundTypeNodes(?NodeAbstract $type): void + { + if ($type === null) { + return; + } + if ($type instanceof NullableType) { + $this->markLateBoundTypeNodes($type->type); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $member) { + $this->markLateBoundTypeNodes($member); + } + return; + } + if ($type instanceof Node\Name) { + $name = strtolower($type->toString()); + if (in_array($name, ['self', 'static', 'parent'], true)) { + $type->setAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, $name); + } + } + } + protected function isStrictScalarType(string $type): bool { return in_array($type, [Type::INT, Type::FLOAT, Type::BOOL, Type::STR], true); @@ -198,17 +223,31 @@ trait TypeCheckGenerator return [$entry]; } - if ($name === 'self') { + $lateBound = $typeNode instanceof Node\Name + ? $typeNode->getAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, '') + : ''; + $lateBound = is_string($lateBound) ? $lateBound : ''; + if ($lateBound === 'self') { $class = $this->getFullClassLikeName(); - } elseif ($name === 'parent') { + } elseif ($lateBound === 'parent') { $class = $this->classDef->extends ?? ''; - } elseif ($name === 'static') { + } elseif ($lateBound === 'static') { $class = 'static'; } else { $class = $this->getNamespacedClassName($name); } - return $class ? [['kind' => 'instanceof', 'class' => $class]] : []; + if ($lateBound !== '') { + $typeNode->setAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE, $lateBound); + } + if ($class === '' && !($lateBound === 'parent' && $this->classDef?->trait !== null)) { + return []; + } + $entry = ['kind' => 'instanceof', 'class' => $class]; + if ($lateBound !== '') { + $entry['lateBound'] = $lateBound; + } + return [$entry]; } protected function typeCheckNodeToString(NodeAbstract $typeNode): string diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 8c91e1ce..d10c7242 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -774,6 +774,7 @@ class Preprocessor extends CompilerBase protected function parseParameterType(Node\Param $param, ArgInfo $argInfo, string $var): string { + $this->markLateBoundTypeNodes($param->type); // 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. @@ -978,6 +979,7 @@ class Preprocessor extends CompilerBase } $fnName = $this->parseIdentifier($v->name); + $this->markLateBoundTypeNodes($v->returnType); // 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. diff --git a/src/Translator.php b/src/Translator.php index d40f3f3c..77724f77 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3067,8 +3067,9 @@ CODE; $traitMethods = []; $traitConstants = []; $traitProperties = []; - $consumingClass = $className->toString(); - $classDef = $this->getClass($consumingClass); + $classDef = $this->getClass($className->toString()); + $usingClassDef = $classDef; + $compositionOwner = $classDef->getNamespacedName(false); foreach ($stmt->stmts as $classStmt) { if ($classStmt instanceof Node\Stmt\ClassMethod) { @@ -3130,7 +3131,11 @@ CODE; // 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); + $traitOrigin = (string) $traitStmt->getAttribute( + self::TRAIT_ORIGIN_ATTRIBUTE, + $traitFullName, + ); + $this->reresolveTraitMethodAstLateBoundTypes($usingClassDef, $traitOrigin, $traitStmt); // Every adaptation derives its flags from the original // method's flags: a same-name visibility change must not // leak into aliases of the same method that are processed @@ -3153,9 +3158,9 @@ CODE; $this->validateTraitAbstractImplementation( $classStmt, $traitStmt, $requirementSource, $requirementDef, - $methods[$aliasName], $consumingClass, + $methods[$aliasName], $compositionOwner, $classDef->methods[$aliasName] ?? $classDef->abstractMethodDefs[$aliasName] ?? null, - $consumingClass, + $usingClassDef, ); } } elseif (!isset($traitMethods[$aliasName])) { @@ -3183,9 +3188,9 @@ CODE; $this->validateTraitAbstractImplementation( $classStmt, $traitStmt, $requirementSource, $requirementDef, - $methods[$methodName], $consumingClass, + $methods[$methodName], $compositionOwner, $classDef->methods[$methodName] ?? $classDef->abstractMethodDefs[$methodName] ?? null, - $consumingClass, + $usingClassDef, ); } unset($traitStmts[$k1]); @@ -3216,7 +3221,7 @@ CODE; $classStmt, $traitStmt, $requirementSource, $requirementDef, $existingStmt, $implementationSource, $implementationDef, - $consumingClass, + $usingClassDef, ); unset($traitStmts[$k1]); continue; @@ -3233,7 +3238,7 @@ CODE; $classStmt, $existingStmt, $requirementSource, $requirementDef, $traitStmt, $implementationSource, $implementationDef, - $consumingClass, + $usingClassDef, ); foreach ($stmt->stmts as $k3 => $mergedStmt) { if ($mergedStmt === $existingStmt) { @@ -3374,10 +3379,11 @@ CODE; Node\Stmt\ClassMethod $implementation, string $implementationSource, ?MethodDef $implementationDef, - string $consumingClass, + ClassDef $usingClassDef, ): void { $requirementName = $requirement->name->toString(); $implementationName = $implementation->name->toString(); + $consumingClass = $usingClassDef->getNamespacedName(false); if ($requirement->isStatic() !== $implementation->isStatic()) { $this->fatalError($errorNode, $requirement->isStatic() @@ -3436,13 +3442,12 @@ CODE; if ($implArg === null) { continue; } - // Late-bound keywords (`self`, `static`, `parent`) were resolved - // against different declaring types but denote the same type once - // both methods are flattened into the consuming class. - if ($requiredArg->typeKeyword !== '' && $requiredArg->typeKeyword === $implArg->typeKeyword) { - continue; - } - if (!$this->isParameterTypeOverrideCompatible($implArg, $requiredArg)) { + if (!$this->isTraitParameterTypeCompatible( + $implArg, + $requiredArg, + $usingClassDef, + $errorNode, + )) { $incompatible(); } } @@ -3453,20 +3458,15 @@ CODE; if ($implementationFunc->returnTypeUndeclared) { $incompatible(); } - if ($requirementFunc->returnTypeKeyword !== '' - && $requirementFunc->returnTypeKeyword === $implementationFunc->returnTypeKeyword - ) { - return; - } - $requirementTypes = $this->remapInstanceofClass( - $this->getReturnAcceptedTypes($requirementFunc, $consumingClass), - $requirementSource, - $consumingClass, + $requirementTypes = $this->getTraitReturnAcceptedTypes( + $requirementFunc, + $usingClassDef, + $errorNode, ); - $implementationTypes = $this->remapInstanceofClass( - $this->getReturnAcceptedTypes($implementationFunc, $consumingClass), - $implementationSource, - $consumingClass, + $implementationTypes = $this->getTraitReturnAcceptedTypes( + $implementationFunc, + $usingClassDef, + $errorNode, ); foreach ($implementationTypes as $implementationType) { if (!$this->isReturnTypeCoveredBy($implementationType, $requirementTypes)) { @@ -3489,24 +3489,105 @@ CODE; return $required; } - /** - * Replace `instanceof` references to a trait in a DNF type-check list with - * the consuming class. `self` (and `parent`) inside a trait were resolved - * to the trait itself during preprocessing, but once the method is - * flattened they denote the consuming class, and no object can ever be an - * instance of a trait. - */ - private function remapInstanceofClass(array $types, string $from, string $to): array - { - if ($from === $to) { - return $types; + private function isTraitParameterTypeCompatible( + ArgInfo $implementation, + ArgInfo $requirement, + ClassDef $usingClassDef, + Node $errorNode, + ): bool { + if ($this->isTopParameterType($implementation)) { + return true; + } + if ($this->isTopParameterType($requirement)) { + return false; + } + + $requirementTypes = $this->getTraitParameterAcceptedTypes( + $requirement, + $usingClassDef, + $errorNode, + ); + $implementationTypes = $this->getTraitParameterAcceptedTypes( + $implementation, + $usingClassDef, + $errorNode, + ); + if ($requirementTypes === null || $implementationTypes === null) { + return $this->isParameterTypeOverrideCompatible($implementation, $requirement); + } + return $this->isAcceptedTypeSubset($requirementTypes, $implementationTypes); + } + + private function getTraitParameterAcceptedTypes( + ArgInfo $argument, + ClassDef $usingClassDef, + Node $errorNode, + ): ?array { + if ($argument->typeKeyword !== '') { + return $this->getLateBoundTraitAcceptedType($argument->typeKeyword, $usingClassDef, $errorNode); + } + return $this->resolveLateBoundAcceptedTypes( + $this->getParameterAcceptedTypes($argument), + $usingClassDef, + $errorNode, + ); + } + + private function getTraitReturnAcceptedTypes( + FunctionDef $function, + ClassDef $usingClassDef, + Node $errorNode, + ): array { + if ($function->returnTypeKeyword !== '') { + return $this->getLateBoundTraitAcceptedType($function->returnTypeKeyword, $usingClassDef, $errorNode); + } + return $this->resolveLateBoundAcceptedTypes( + $this->getReturnAcceptedTypes($function, $usingClassDef->getNamespacedName(false)), + $usingClassDef, + $errorNode, + ) ?? []; + } + + private function getLateBoundTraitAcceptedType( + string $keyword, + ClassDef $usingClassDef, + Node $errorNode, + ): array { + if ($keyword === 'static') { + return [['kind' => 'isStatic', 'class' => $usingClassDef->getNamespacedName(false)]]; + } + $class = $this->resolveLateBoundClass($usingClassDef, $keyword); + if ($class === null) { + $this->fatalError($errorNode, 'Cannot use "parent" when current class scope has no parent'); + } + return [['kind' => 'instanceof', 'class' => $class]]; + } + + private function resolveLateBoundAcceptedTypes( + ?array $types, + ClassDef $usingClassDef, + Node $errorNode, + ): ?array { + if ($types === null) { + return null; } foreach ($types as &$type) { if (($type['kind'] ?? null) === 'allOf') { - $type['types'] = $this->remapInstanceofClass($type['types'], $from, $to); - } elseif (($type['kind'] ?? null) === 'instanceof' && ($type['class'] ?? null) === $from) { - $type['class'] = $to; + $type['types'] = $this->resolveLateBoundAcceptedTypes( + $type['types'], + $usingClassDef, + $errorNode, + ); + continue; } + $lateBound = $type['lateBound'] ?? ''; + if (!is_string($lateBound) || $lateBound === '') { + continue; + } + $resolved = $this->getLateBoundTraitAcceptedType($lateBound, $usingClassDef, $errorNode)[0]; + $type['kind'] = $resolved['kind']; + $type['class'] = $resolved['class']; + unset($type['lateBound']); } return $types; } @@ -3538,21 +3619,26 @@ CODE; return; } $traitDef = $this->getClass($traitFullName); - if (!$traitDef->hasMethod($methodStmt->name->toString())) { + $methodName = strtolower($methodStmt->name->toString()); + $methodDef = $traitDef->methods[$methodName] + ?? $traitDef->abstractMethodDefs[$methodName] + ?? null; + if ($methodDef === null) { return; } - $fn = $traitDef->getMethod($methodStmt->name->toString())->functionDef; + $fn = $methodDef->functionDef; if ($fn->returnTypeKeyword !== '' && $methodStmt->returnType instanceof Node\Name) { - if ($fn->returnTypeKeyword === 'static') { - // `static` remains late-bound in the composed method signature. - $methodStmt->returnType = new Node\Name('static'); - } else { - $resolved = $this->resolveLateBoundClass($usingClassDef, $fn->returnTypeKeyword); - if ($resolved !== null) { - $methodStmt->returnType = new Node\Name($resolved); - } - } + $methodStmt->returnType = $this->resolveTraitTypeNode( + $methodStmt->returnType, + $fn->returnTypeKeyword, + $usingClassDef, + ); + } elseif ($methodStmt->returnType !== null) { + $methodStmt->returnType = $this->reresolveCompositeTraitTypeNode( + $methodStmt->returnType, + $usingClassDef, + ); } foreach ($fn->argInfoList as $i => $arg) { @@ -3561,16 +3647,89 @@ CODE; && 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); + $methodStmt->params[$i]->type = $this->resolveTraitTypeNode( + $methodStmt->params[$i]->type, + $arg->typeKeyword, + $usingClassDef, + ); + } elseif (isset($methodStmt->params[$i]) && $methodStmt->params[$i]->type !== null) { + $methodStmt->params[$i]->type = $this->reresolveCompositeTraitTypeNode( + $methodStmt->params[$i]->type, + $usingClassDef, + ); + } + } + } + + private function reresolveCompositeTraitTypeNode( + Node\ComplexType|Node\Identifier|Node\Name $type, + ClassDef $usingClassDef, + ): Node\ComplexType|Node\Identifier|Node\Name { + if ($type instanceof Node\NullableType) { + $type->type = $this->reresolveTraitTypeMember($type->type, $usingClassDef); + return $type; + } + if ($type instanceof Node\IntersectionType) { + foreach ($type->types as $i => $member) { + $type->types[$i] = $this->reresolveTraitTypeMember($member, $usingClassDef); + } + return $type; + } + if ($type instanceof Node\UnionType) { + foreach ($type->types as $i => $member) { + if ($member instanceof Node\IntersectionType) { + foreach ($member->types as $j => $intersectionMember) { + $member->types[$j] = $this->reresolveTraitTypeMember( + $intersectionMember, + $usingClassDef, + ); } + } else { + $type->types[$i] = $this->reresolveTraitTypeMember($member, $usingClassDef); } } + return $type; } + return $this->reresolveTraitTypeMember($type, $usingClassDef); + } + + private function reresolveTraitTypeMember( + Node\Identifier|Node\Name $type, + ClassDef $usingClassDef, + ): Node\Identifier|Node\Name { + if (!$type instanceof Node\Name) { + return $type; + } + $keyword = $type->getAttribute(self::LATE_BOUND_TYPE_ATTRIBUTE); + if (!is_string($keyword) || $keyword === '') { + return $type; + } + return $this->resolveTraitTypeNode($type, $keyword, $usingClassDef); + } + + private function resolveTraitTypeNode( + Node\Name $type, + string $keyword, + ClassDef $usingClassDef, + ): Node\Name { + if ($keyword === 'static') { + return new Node\Name('static', $type->getAttributes()); + } + $resolved = $this->resolveLateBoundClass($usingClassDef, $keyword); + if ($resolved === null) { + // A trait can import another trait whose signature contains + // `parent` before any class consumes either one. PHP keeps that + // type late-bound until the outer trait is flattened into a + // class. Signature compatibility checks performed while traits + // are composed still reject `parent` in this scope, because + // there is no parent against which the two methods can be + // compared. + if ($keyword === 'parent' && $usingClassDef->trait !== null) { + return new Node\Name('parent', $type->getAttributes()); + } + $this->fatalError($type, 'Cannot use "parent" when current class scope has no parent'); + } + return new Node\Name\FullyQualified($resolved, $type->getAttributes()); } /** diff --git a/tests/compiler/trait/trait-abstract-late-bound-types.phpt b/tests/compiler/trait/trait-abstract-late-bound-types.phpt new file mode 100644 index 00000000..f5a9468f --- /dev/null +++ b/tests/compiler/trait/trait-abstract-late-bound-types.phpt @@ -0,0 +1,112 @@ +--TEST-- +Trait abstract requirements resolve self and parent in the composition scope +--FILE-- +selfValue($consumer) === $consumer); + + $base = new LateBoundBase(); + var_dump($consumer->parentValue($base) === $base); + + $nested = new NestedSelfConsumer(); + var_dump($nested->copy($nested) === $nested); + + $nestedParentMethod = new NestedParentMethodConsumer(); + var_dump($nestedParentMethod->parentFromNestedTrait() instanceof LateBoundBase); + + $nestedParentRequirement = new NestedParentRequirementConsumer(); + var_dump($nestedParentRequirement->nestedParent($base) === $base); + + $deferred = new DeferredImplementation(); + var_dump($deferred->deferred(null) === $deferred); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true)