From 830e5602e7de79303ccd5fb99ed3dbe611fe9fc3 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Wed, 14 Oct 2026 18:31:29 +0300 Subject: [PATCH] Resolve a self-typed parameter to its declaring class A generated override wrote `self` through, and inside the generated class that names the generated subclass, so PHP 8.3/8.4 rejected the signature as narrowed. A trait method was affected on 8.5 too. The keyword check in qualify() was also case-sensitive, so `SELF` rendered as `\SELF`. Fixes #158 --- CHANGELOG.md | 15 +++++ src/Codegen/TypeRenderer.php | 39 ++++++++++++- tests/Codegen/TargetUnifierEdgeTest.php | 55 +++++++++++++++++- tests/Codegen/TypeRendererTest.php | 56 +++++++++++++++++++ tests/Fixture/Unify/SelfParameter.php | 22 ++++++++ tests/Fixture/Unify/SelfParameterContract.php | 10 ++++ tests/Fixture/Unify/SelfParameterTrait.php | 17 ++++++ .../Fixture/Unify/SelfParameterTraitUser.php | 10 ++++ 8 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/Fixture/Unify/SelfParameter.php create mode 100644 tests/Fixture/Unify/SelfParameterContract.php create mode 100644 tests/Fixture/Unify/SelfParameterTrait.php create mode 100644 tests/Fixture/Unify/SelfParameterTraitUser.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 050bc47..a91cff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +- **Fixed.** Doubling a type whose method declares a parameter as `self` no + longer kills the process with a fatal on PHP 8.3 and 8.4 (#158). The + generated override wrote the keyword through, and inside the generated class + `self` is the generated class — a subclass of the doubled one — so the + parameter accepted less than the contract and PHP rejected the signature. + It now renders as the declaring class, the way `parent` already did. A method + that comes from a trait was affected on PHP 8.5 too: Reflection leaves a + trait's `self` unresolved on every version. +- **Fixed.** A relative type keyword spelled in another case — `SELF`, + `Parent`, `STATIC` — was rendered as a class name with a leading backslash, + which PHP 8.3 and 8.4 refuse with a fatal: Reflection keeps the spelling, and + the keyword check compared it case-sensitively. + ## 1.1.0 — 2026-10-13 - **Changed.** A forwarding double of a **class** now runs the real bodies on diff --git a/src/Codegen/TypeRenderer.php b/src/Codegen/TypeRenderer.php index 3ebe37d..add6174 100644 --- a/src/Codegen/TypeRenderer.php +++ b/src/Codegen/TypeRenderer.php @@ -37,7 +37,10 @@ public static function parameterType(?\ReflectionType $type, ?\ReflectionClass $ return ''; } - return self::resolveParent(self::renderParameter($type), $declaringClass); + return self::resolveSelf( + self::resolveParent(self::renderParameter($type), $declaringClass), + $declaringClass, + ); } /** @@ -128,6 +131,38 @@ private static function resolveParent(string $rendered, ?\ReflectionClass $decla return $resolved === null || $resolved === '' ? $rendered : $resolved; } + /** + * Replaces the `self` keyword in a parameter type with the class it names. + * + * Inside the generated class `self` is the generated class — a subclass of + * the target — so a parameter written through would accept less than the + * contract does, and PHP rejects the override as narrowed. A return keeps + * the keyword: there a subclass is a legal narrowing. + * + * PHP 8.5 resolves `self` in Reflection for a class or an interface; 8.3 + * and 8.4 do not, and no version does for a method that came from a trait. + * The declaring class Reflection reports for a trait method is the class + * using the trait, which is what `self` means there. + * + * @param non-empty-string $rendered + * + * @return non-empty-string + */ + private static function resolveSelf(string $rendered, ?\ReflectionClass $declaringClass): string + { + if ($declaringClass === null) { + return $rendered; + } + + $resolved = preg_replace( + '/(?getName(), + $rendered, + ); + + return $resolved === null || $resolved === '' ? $rendered : $resolved; + } + /** * @return non-empty-string */ @@ -164,7 +199,7 @@ private static function qualify(\ReflectionNamedType $type): string { $name = $type->getName(); - return $type->isBuiltin() || in_array($name, ['self', 'static', 'parent'], strict: true) + return $type->isBuiltin() || in_array(strtolower($name), ['self', 'static', 'parent'], strict: true) ? $name : '\\' . $name; } diff --git a/tests/Codegen/TargetUnifierEdgeTest.php b/tests/Codegen/TargetUnifierEdgeTest.php index ef731ab..994c698 100644 --- a/tests/Codegen/TargetUnifierEdgeTest.php +++ b/tests/Codegen/TargetUnifierEdgeTest.php @@ -15,6 +15,9 @@ use Rasuvaeff\Understudy\Tests\Fixture\Unify\ParentReturnChild; use Rasuvaeff\Understudy\Tests\Fixture\Unify\ParentReturnConflict; use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfConstantExpression; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfParameter; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfParameterContract; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfParameterTraitUser; use Rasuvaeff\Understudy\Tests\Fixture\Unify\TraversableReturn; use Rasuvaeff\Understudy\Tests\Fixture\Unify\TypedReturn; use Rasuvaeff\Understudy\Tests\Fixture\Unify\UntypedReturn; @@ -22,12 +25,13 @@ use Rasuvaeff\Understudy\Understudy; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Data\DataProvider; use Testo\Lifecycle\AfterTest; use Testo\Test; /** * Declarations the main unifier suite does not reach: an absent return type, - * the `parent` keyword, a nullable object rendered as `?X`, a default that is + * the `parent` and `self` keywords, a nullable object rendered as `?X`, a default that is * an expression rather than a constant, and two returns where one subsumes * the other. * @@ -115,6 +119,55 @@ public function theParentKeywordIsResolvedInAParameterToo(): void Assert::false(str_contains($rendered, 'parent')); } + /** + * @param class-string $target + */ + #[DataProvider('selfParameterProvider')] + public function theSelfKeywordIsResolvedInAParameter(string $target, string $method): void + { + // Inside the generated class `self` is the generated class, so written + // through it narrows the parameter and PHP 8.3/8.4 refuse the class. + $double = Understudy::for($target); + + Assert::same($double->{$method}(new SelfParameter()), ''); + + $rendered = (string) (new \ReflectionMethod($double, $method))->getParameters()[0]->getType(); + + Assert::string($rendered)->contains(SelfParameter::class); + Assert::same(preg_match('/(? + */ + public static function selfParameterProvider(): iterable + { + yield 'class' => [SelfParameter::class, 'mergeWith']; + yield 'nullable' => [SelfParameter::class, 'mergeMaybe']; + } + + public function theSelfKeywordIsResolvedToTheInterfaceItIsDeclaredOn(): void + { + $double = Understudy::for(SelfParameterContract::class); + + $rendered = (string) (new \ReflectionMethod($double, 'mergeWith'))->getParameters()[0]->getType(); + + Assert::string($rendered)->contains(SelfParameterContract::class); + Assert::same($double->mergeWith($double), ''); + } + + public function theSelfKeywordFromATraitIsResolvedToTheUsingClass(): void + { + // A trait's `self` stays literal in Reflection even on 8.5; the class + // Reflection names as declaring it is the one using the trait. + $double = Understudy::for(SelfParameterTraitUser::class); + + $rendered = (string) (new \ReflectionMethod($double, 'mergeWith'))->getParameters()[0]->getType(); + + Assert::string($rendered)->contains(SelfParameterTraitUser::class); + Assert::same($double->mergeWith(new SelfParameterTraitUser()), ''); + } + public function aNullableObjectParameterUnifiesWithAWiderUnion(): void { // `?ParentReturnBase` is rendered with a leading `?`, which the union diff --git a/tests/Codegen/TypeRendererTest.php b/tests/Codegen/TypeRendererTest.php index f95c4e2..40bdd0a 100644 --- a/tests/Codegen/TypeRendererTest.php +++ b/tests/Codegen/TypeRendererTest.php @@ -7,6 +7,8 @@ use Rasuvaeff\Understudy\Codegen\TypeRenderer; use Rasuvaeff\Understudy\Tests\Fixture\Unify\ReaderInt; use Rasuvaeff\Understudy\Tests\Fixture\Unify\ReaderStringy; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfParameter; +use Rasuvaeff\Understudy\Tests\Fixture\Unify\SelfParameterTraitUser; use Rasuvaeff\Understudy\Tests\Fixture\Unify\TypeShowcase; use Testo\Assert; use Testo\Codecov\Covers; @@ -142,6 +144,60 @@ public function anIntersectionRendersEveryAtom(): void ); } + #[DataProvider('selfParameterProvider')] + public function aSelfParameterIsRenderedAsTheDeclaringClass(string $class, string $method, string $expected): void + { + $parameter = (new \ReflectionMethod($class, $method))->getParameters()[0]; + + Assert::same( + TypeRenderer::parameterType($parameter->getType(), $parameter->getDeclaringClass()), + $expected, + ); + } + + /** + * @return iterable + */ + public static function selfParameterProvider(): iterable + { + yield 'plain' => [SelfParameter::class, 'mergeWith', '\\' . SelfParameter::class]; + yield 'nullable' => [SelfParameter::class, 'mergeMaybe', '\\' . SelfParameter::class . '|null']; + yield 'from a trait' => [SelfParameterTraitUser::class, 'mergeWith', '\\' . SelfParameterTraitUser::class]; + } + + public function theSelfKeywordIsMatchedInAnyCase(): void + { + // Keywords are case-insensitive and Reflection keeps the spelling, so + // `SELF` reaches here verbatim — on 8.3 and 8.4 from anywhere, and on + // 8.5 from a trait, which is why the method lives in one. Declared + // through eval because the code style fixer lowercases it in a file. + if (!class_exists('Rasuvaeff\\Understudy\\Tests\\Codegen\\UpperSelfParameter', autoload: false)) { + eval('namespace Rasuvaeff\\Understudy\\Tests\\Codegen; trait UpperSelfTrait { public function merge(SELF $other): SELF { return $this; } } class UpperSelfParameter { use UpperSelfTrait; }'); + } + + $parameter = (new \ReflectionMethod('Rasuvaeff\\Understudy\\Tests\\Codegen\\UpperSelfParameter', 'merge'))->getParameters()[0]; + + Assert::same( + TypeRenderer::parameterType($parameter->getType(), $parameter->getDeclaringClass()), + '\\Rasuvaeff\\Understudy\\Tests\\Codegen\\UpperSelfParameter', + ); + // In a return the keyword stays a keyword where Reflection reports one + // (8.3, 8.4) and is the class where it resolves it (8.5) — never + // `\\SELF`, a class name PHP refuses outright. + $return = TypeRenderer::returnType( + (new \ReflectionMethod('Rasuvaeff\\Understudy\\Tests\\Codegen\\UpperSelfParameter', 'merge'))->getReturnType(), + ); + + Assert::true(\in_array($return, ['SELF', '\\Rasuvaeff\\Understudy\\Tests\\Codegen\\UpperSelfParameter'], strict: true)); + } + + public function aSelfParameterWithoutADeclaringClassIsLeftAlone(): void + { + $type = (new \ReflectionMethod(SelfParameterTraitUser::class, 'mergeWith'))->getParameters()[0]->getType(); + + Assert::same(TypeRenderer::parameterType($type), 'self'); + } + private function typeOf(string $method): ?\ReflectionType { return (new \ReflectionMethod(TypeShowcase::class, $method))->getParameters()[0]->getType(); diff --git a/tests/Fixture/Unify/SelfParameter.php b/tests/Fixture/Unify/SelfParameter.php new file mode 100644 index 0000000..220b9a0 --- /dev/null +++ b/tests/Fixture/Unify/SelfParameter.php @@ -0,0 +1,22 @@ +