Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
39 changes: 37 additions & 2 deletions src/Codegen/TypeRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand Down Expand Up @@ -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(
'/(?<![\\\\\\w])self(?![\\\\\\w])/i',
'\\\\' . $declaringClass->getName(),
$rendered,
);

return $resolved === null || $resolved === '' ? $rendered : $resolved;
}

/**
* @return non-empty-string
*/
Expand Down Expand Up @@ -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;
}
Expand Down
55 changes: 54 additions & 1 deletion tests/Codegen/TargetUnifierEdgeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,23 @@
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;
use Rasuvaeff\Understudy\Tests\Fixture\Unify\WiderObjectParam;
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.
*
Expand Down Expand Up @@ -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('/(?<![\\\\\\w])self(?![\\\\\\w])/i', $rendered), 0);
}

/**
* @return iterable<string, array{class-string, string}>
*/
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
Expand Down
56 changes: 56 additions & 0 deletions tests/Codegen/TypeRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, array{class-string, string, string}>
*/
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();
Expand Down
22 changes: 22 additions & 0 deletions tests/Fixture/Unify/SelfParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Understudy\Tests\Fixture\Unify;

/**
* `self` as a PARAMETER type. Written through, it would mean the generated
* class — a subclass — and PHP 8.3/8.4 reject the override as narrowed.
*/
class SelfParameter
{
public function mergeWith(self $other): string
{
return 'merged';
}

public function mergeMaybe(?self $other): string
{
return $other === null ? 'alone' : 'merged';
}
}
10 changes: 10 additions & 0 deletions tests/Fixture/Unify/SelfParameterContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Understudy\Tests\Fixture\Unify;

interface SelfParameterContract
{
public function mergeWith(self $other): string;
}
17 changes: 17 additions & 0 deletions tests/Fixture/Unify/SelfParameterTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Understudy\Tests\Fixture\Unify;

/**
* Reflection reports `self` from a trait literally on every version, 8.5
* included, so this is the shape a local 8.5 run still catches.
*/
trait SelfParameterTrait
{
public function mergeWith(self $other): string
{
return 'merged';
}
}
10 changes: 10 additions & 0 deletions tests/Fixture/Unify/SelfParameterTraitUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\Understudy\Tests\Fixture\Unify;

class SelfParameterTraitUser
{
use SelfParameterTrait;
}
Loading