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
2 changes: 1 addition & 1 deletion src/Commands/ShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public function handle(HookPressManager $manager): int
$type = $this->argument('type');

$map = $manager->map(is_string($type) ? $type : null);
if ($type && is_string($type)) {
if (is_string($type) && $type) {
if ($map === []) {
$this->components->warn("No entries for '{$type}'.");

Expand Down
4 changes: 2 additions & 2 deletions src/Conditions/HasMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace HookPress\Conditions;

use HookPress\Contracts\Condition;
use HookPress\Support\TypeName;
use ReflectionClass;
use ReflectionException;

Expand Down Expand Up @@ -53,8 +54,7 @@ public function passes(ReflectionClass $ref, mixed $arg = null): bool
}

if (! empty($arg['returns']) && is_string($arg['returns'])) {
$type = $m->getReturnType();
$actual = $type ? ltrim((string) $type, '\\') : '';
$actual = ltrim(TypeName::from($m->getReturnType()), '\\');
$expected = ltrim($arg['returns'], '\\');
if ($expected !== '' && $actual !== $expected) {
return false;
Expand Down
4 changes: 2 additions & 2 deletions src/Conditions/HasProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace HookPress\Conditions;

use HookPress\Contracts\Condition;
use HookPress\Support\TypeName;
use ReflectionClass;

class HasProperty implements Condition
Expand Down Expand Up @@ -48,8 +49,7 @@ public function passes(ReflectionClass $ref, mixed $arg = null): bool
}

if (! empty($arg['type']) && is_string($arg['type'])) {
$type = $p->getType();
$actual = $type ? ltrim((string) $type, '\\') : '';
$actual = ltrim(TypeName::from($p->getType()), '\\');
$expected = ltrim($arg['type'], '\\');
if ($expected !== '' && $actual !== $expected) {
return false;
Expand Down
69 changes: 69 additions & 0 deletions src/Support/TypeName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace HookPress\Support;

use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;

/**
* Renders a reflected type exactly as ReflectionType::__toString() does,
* without calling it — that method has been deprecated since PHP 8.0.
*/
final class TypeName
{
public static function from(?ReflectionType $type): string
{
if ($type instanceof ReflectionNamedType) {
return self::named($type);
}

if ($type instanceof ReflectionIntersectionType) {
return self::intersection($type);
}

if ($type instanceof ReflectionUnionType) {
return self::union($type);
}

return '';
}

private static function named(ReflectionNamedType $type): string
{
$name = $type->getName();

// `null` and `mixed` are nullable by definition, so PHP never prefixes them.
return $type->allowsNull() && $name !== 'null' && $name !== 'mixed'
? '?'.$name
: $name;
}

private static function union(ReflectionUnionType $type): string
{
$parts = [];

foreach ($type->getTypes() as $member) {
// A DNF type nests an intersection inside a union: (A&B)|C.
$parts[] = $member instanceof ReflectionIntersectionType
? '('.self::from($member).')'
: self::from($member);
}

return implode('|', $parts);
}

private static function intersection(ReflectionIntersectionType $type): string
{
$parts = [];

foreach ($type->getTypes() as $member) {
$parts[] = self::from($member);
}

return implode('&', $parts);
}
}
7 changes: 7 additions & 0 deletions tests/Fixtures/App/Classes/Types/Alpha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Classes\Types;

interface Alpha {}
7 changes: 7 additions & 0 deletions tests/Fixtures/App/Classes/Types/Beta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

declare(strict_types=1);

namespace App\Classes\Types;

interface Beta {}
18 changes: 18 additions & 0 deletions tests/Fixtures/App/Classes/Types/DnfShowcase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Classes\Types;

/**
* Disjunctive Normal Form types are PHP 8.2+, so this lives in its own file:
* the test that references it is skipped below 8.2 and the file is then never
* autoloaded, let alone parsed.
*/
class DnfShowcase
{
public function dnf(): (Alpha&Beta)|string
{
return '';
}
}
74 changes: 74 additions & 0 deletions tests/Fixtures/App/Classes/Types/TypeShowcase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace App\Classes\Types;

use App\Interfaces\PayoutMethod;

/**
* Every type shape TypeName has to render, so the unit test can assert it
* against the string PHP itself would produce.
*
* Not registered in the fake classmap — discovery must not pick it up.
*/
class TypeShowcase
{
public string $scalar = '';

public ?int $nullableScalar = null;

public mixed $anything = null;

public int|string $union = 0;

public int|string|null $nullableUnion = null;

public ?PayoutMethod $nullableClass = null;

public $untyped;

public function scalar(): string
{
return '';
}

public function nullable(): ?int
{
return null;
}

public function anything(): mixed
{
return null;
}

public function nothing(): void {}

public function onlyNull(): null
{
return null;
}

public function union(): int|string
{
return 0;
}

public function nullableUnion(): int|string|null
{
return null;
}

public function intersection(): Alpha&Beta
{
throw new \LogicException('never called');
}

public function nullableClass(): ?PayoutMethod
{
return null;
}

public function untyped() {}
}
93 changes: 93 additions & 0 deletions tests/Unit/TypeNameTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

use App\Classes\Types\DnfShowcase;
use App\Classes\Types\TypeShowcase;
use HookPress\Conditions\HasMethod;
use HookPress\Conditions\HasProperty;
use HookPress\Support\TypeName;

/**
* @return array<string,string>
*/
function returnTypeNames(string $class): array
{
$names = [];

foreach ((new ReflectionClass($class))->getMethods() as $method) {
$names[$method->getName()] = TypeName::from($method->getReturnType());
}

return $names;
}

it('renders every return type shape the way PHP does', function (): void {
expect(returnTypeNames(TypeShowcase::class))->toBe([
'scalar' => 'string',
'nullable' => '?int',
'anything' => 'mixed',
'nothing' => 'void',
'onlyNull' => 'null',
'union' => 'string|int',
'nullableUnion' => 'string|int|null',
'intersection' => 'App\Classes\Types\Alpha&App\Classes\Types\Beta',
'nullableClass' => '?App\Interfaces\PayoutMethod',
'untyped' => '',
]);
});

it('renders property types', function (): void {
$ref = new ReflectionClass(TypeShowcase::class);

$names = [];
foreach ($ref->getProperties() as $property) {
$names[$property->getName()] = TypeName::from($property->getType());
}

expect($names)->toBe([
'scalar' => 'string',
'nullableScalar' => '?int',
'anything' => 'mixed',
'union' => 'string|int',
'nullableUnion' => 'string|int|null',
'nullableClass' => '?App\Interfaces\PayoutMethod',
'untyped' => '',
]);
});

it('parenthesises the intersection inside a DNF type', function (): void {
expect(returnTypeNames(DnfShowcase::class))->toBe([
'dnf' => '(App\Classes\Types\Alpha&App\Classes\Types\Beta)|string',
]);
})->skip(PHP_VERSION_ID < 80200, 'DNF types require PHP 8.2.');

it('returns an empty string when there is no type at all', function (): void {
expect(TypeName::from(null))->toBe('');
});

it('matches types through the HasMethod condition', function (): void {
$ref = new ReflectionClass(TypeShowcase::class);
$condition = new HasMethod;

expect($condition->passes($ref, ['name' => 'nullable', 'returns' => '?int']))->toBeTrue()
->and($condition->passes($ref, ['name' => 'nullable', 'returns' => 'int']))->toBeFalse()
->and($condition->passes($ref, ['name' => 'union', 'returns' => 'string|int']))->toBeTrue()
// Leading backslashes are trimmed on both sides.
->and($condition->passes($ref, [
'name' => 'nullableClass',
'returns' => '?App\Interfaces\PayoutMethod',
]))->toBeTrue()
// An untyped method has no type to match against.
->and($condition->passes($ref, ['name' => 'untyped', 'returns' => 'string']))->toBeFalse();
});

it('matches types through the HasProperty condition', function (): void {
$ref = new ReflectionClass(TypeShowcase::class);
$condition = new HasProperty;

expect($condition->passes($ref, ['name' => 'nullableScalar', 'type' => '?int']))->toBeTrue()
->and($condition->passes($ref, ['name' => 'nullableScalar', 'type' => 'int']))->toBeFalse()
->and($condition->passes($ref, ['name' => 'nullableUnion', 'type' => 'string|int|null']))->toBeTrue()
->and($condition->passes($ref, ['name' => 'untyped', 'type' => 'string']))->toBeFalse();
});