diff --git a/src/Commands/ShowCommand.php b/src/Commands/ShowCommand.php index de570d2..db110e5 100644 --- a/src/Commands/ShowCommand.php +++ b/src/Commands/ShowCommand.php @@ -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}'."); diff --git a/src/Conditions/HasMethod.php b/src/Conditions/HasMethod.php index f201be6..93ca0c4 100644 --- a/src/Conditions/HasMethod.php +++ b/src/Conditions/HasMethod.php @@ -5,6 +5,7 @@ namespace HookPress\Conditions; use HookPress\Contracts\Condition; +use HookPress\Support\TypeName; use ReflectionClass; use ReflectionException; @@ -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; diff --git a/src/Conditions/HasProperty.php b/src/Conditions/HasProperty.php index 686e2e5..cb576de 100644 --- a/src/Conditions/HasProperty.php +++ b/src/Conditions/HasProperty.php @@ -5,6 +5,7 @@ namespace HookPress\Conditions; use HookPress\Contracts\Condition; +use HookPress\Support\TypeName; use ReflectionClass; class HasProperty implements Condition @@ -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; diff --git a/src/Support/TypeName.php b/src/Support/TypeName.php new file mode 100644 index 0000000..988538d --- /dev/null +++ b/src/Support/TypeName.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/tests/Fixtures/App/Classes/Types/Alpha.php b/tests/Fixtures/App/Classes/Types/Alpha.php new file mode 100644 index 0000000..4bfab44 --- /dev/null +++ b/tests/Fixtures/App/Classes/Types/Alpha.php @@ -0,0 +1,7 @@ + + */ +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(); +});