From 55a06b074983b3e4c778c7a36a2f0da27d70ca77 Mon Sep 17 00:00:00 2001 From: Maxime Veber Date: Wed, 16 Apr 2025 18:31:44 +0200 Subject: [PATCH] Add support of intersection for return type and dnf --- .../Generator/ClassCodeGeneratorSpec.php | 50 ++++++- .../Doubler/Generator/Node/ClassNodeSpec.php | 4 +- .../Node/Type/IntersectionTypeSpec.php | 51 +++++++ .../Generator/Node/Type/SimpleTypeSpec.php | 40 ++++++ .../Generator/Node/Type/UnionTypeSpec.php | 82 +++++++++++ .../Doubler/Generator/ClassCodeGenerator.php | 37 ++++- .../Doubler/Generator/ClassMirror.php | 86 +++++++++-- .../Doubler/Generator/Node/MethodNode.php | 2 +- .../Doubler/Generator/Node/ReturnTypeNode.php | 29 ++-- .../Generator/Node/Type/IntersectionType.php | 67 +++++++++ .../Generator/Node/Type/SimpleType.php | 84 +++++++++++ .../Generator/Node/Type/TypeInterface.php | 8 ++ .../Doubler/Generator/Node/Type/UnionType.php | 99 +++++++++++++ .../Generator/Node/TypeNodeAbstract.php | 133 +++++++++++++++--- src/Prophecy/Prophecy/ObjectProphecy.php | 4 +- tests/Doubler/Generator/ClassMirrorTest.php | 48 +++++-- 16 files changed, 749 insertions(+), 75 deletions(-) create mode 100644 spec/Prophecy/Doubler/Generator/Node/Type/IntersectionTypeSpec.php create mode 100644 spec/Prophecy/Doubler/Generator/Node/Type/SimpleTypeSpec.php create mode 100644 spec/Prophecy/Doubler/Generator/Node/Type/UnionTypeSpec.php create mode 100644 src/Prophecy/Doubler/Generator/Node/Type/IntersectionType.php create mode 100644 src/Prophecy/Doubler/Generator/Node/Type/SimpleType.php create mode 100644 src/Prophecy/Doubler/Generator/Node/Type/TypeInterface.php create mode 100644 src/Prophecy/Doubler/Generator/Node/Type/UnionType.php diff --git a/spec/Prophecy/Doubler/Generator/ClassCodeGeneratorSpec.php b/spec/Prophecy/Doubler/Generator/ClassCodeGeneratorSpec.php index 5de4fc8be..6289fa564 100644 --- a/spec/Prophecy/Doubler/Generator/ClassCodeGeneratorSpec.php +++ b/spec/Prophecy/Doubler/Generator/ClassCodeGeneratorSpec.php @@ -10,6 +10,9 @@ use Prophecy\Doubler\Generator\Node\ClassNode; use Prophecy\Doubler\Generator\Node\MethodNode; use Prophecy\Doubler\Generator\Node\ReturnTypeNode; +use Prophecy\Doubler\Generator\Node\Type\IntersectionType; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; +use Prophecy\Doubler\Generator\Node\Type\UnionType; class ClassCodeGeneratorSpec extends ObjectBehavior { @@ -115,10 +118,10 @@ class CustomClass extends \RuntimeException implements \Prophecy\Doubler\Generat public $name; private $email; -public static function getName(array $fullname, \ReflectionClass $class, object $instance): ?string { +public static function getName(array $fullname, \ReflectionClass $class, object $instance): string|null { return $this->name; } -protected function getEmail(?string $default = 'ever.zet@gmail.com') { +protected function getEmail(string|null $default = 'ever.zet@gmail.com') { return $this->email; } public function &getRefValue( $refValue): string { @@ -271,7 +274,7 @@ function it_overrides_properly_methods_with_args_passed_by_reference( namespace { class CustomClass extends \RuntimeException implements \Prophecy\Doubler\Generator\MirroredInterface { -public function getName(?array &$fullname = NULL) { +public function getName(array|null &$fullname = NULL) { return $this->name; } @@ -310,6 +313,47 @@ public function foo(): int|string|null { } +} +} +PHP; + $expected = strtr($expected, array("\r\n" => "\n", "\r" => "\n")); + + $code->shouldBe($expected); + } + + function it_generates_proper_code_for_intersection_return_types( + ClassNode $class, + MethodNode $method + ) { + $class->getParentClass()->willReturn('stdClass'); + $class->getInterfaces()->willReturn([]); + $class->getProperties()->willReturn([]); + $class->getMethods()->willReturn(array($method)); + $class->isReadOnly()->willReturn(false); + + $method->getName()->willReturn('foo'); + $method->getVisibility()->willReturn('public'); + $method->isStatic()->willReturn(false); + $method->getArguments()->willReturn([]); + $method->getReturnTypeNode()->willReturn(new ReturnTypeNode( + new UnionType([ + new IntersectionType([new SimpleType('Foo'), new SimpleType('Bar')]), + new SimpleType('string'), + ]) + )); + $method->returnsReference()->willReturn(false); + $method->getCode()->willReturn(''); + + $code = $this->generate('CustomClass', $class); + + $expected = <<<'PHP' +namespace { +class CustomClass extends \stdClass implements { + +public function foo(): \Foo&\Bar|string { + +} + } } PHP; diff --git a/spec/Prophecy/Doubler/Generator/Node/ClassNodeSpec.php b/spec/Prophecy/Doubler/Generator/Node/ClassNodeSpec.php index 0c5e07f6c..fbf1a99cf 100644 --- a/spec/Prophecy/Doubler/Generator/Node/ClassNodeSpec.php +++ b/spec/Prophecy/Doubler/Generator/Node/ClassNodeSpec.php @@ -76,10 +76,10 @@ function it_can_has_methods(MethodNode $method1, MethodNode $method2) $this->addMethod($method1); $this->addMethod($method2); - $this->getMethods()->shouldReturn(array( + $this->getMethods()->shouldReturn([ '__construct' => $method1, 'getName' => $method2, - )); + ]); } function its_hasMethod_returns_true_if_method_exists(MethodNode $method) diff --git a/spec/Prophecy/Doubler/Generator/Node/Type/IntersectionTypeSpec.php b/spec/Prophecy/Doubler/Generator/Node/Type/IntersectionTypeSpec.php new file mode 100644 index 000000000..4c61c7c19 --- /dev/null +++ b/spec/Prophecy/Doubler/Generator/Node/Type/IntersectionTypeSpec.php @@ -0,0 +1,51 @@ +beConstructedWith([ + new SimpleType('Foo'), + new SimpleType('Bar'), + ]); + } + + function it_should_implement_type_union(): void + { + $this->shouldImplement(TypeInterface::class); + } + + function it_should_throw_double_exception_for_builtin_types() + { + $this->beConstructedWith([ + new SimpleType('string'), + new SimpleType('Foo'), + ]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_should_throw_double_exception_if_less_than_2_types_provided() + { + $this->beConstructedWith([ + new SimpleType('Bar'), + ]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_should_throw_double_exception_if_union_type_given(): void + { + $this->beConstructedWith([ + new SimpleType('Bar'), + new UnionType([new SimpleType('Foo'), new SimpleType('Baz')]), + ]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } +} diff --git a/spec/Prophecy/Doubler/Generator/Node/Type/SimpleTypeSpec.php b/spec/Prophecy/Doubler/Generator/Node/Type/SimpleTypeSpec.php new file mode 100644 index 000000000..5dcc7f7ed --- /dev/null +++ b/spec/Prophecy/Doubler/Generator/Node/Type/SimpleTypeSpec.php @@ -0,0 +1,40 @@ +beConstructedWith('string'); + } + + function it_implements_type_interface(): void + { + $this->shouldImplement(TypeInterface::class); + } + + function it_is_stringable(): void + { + $this->beConstructedWith('int'); + $this->getType()->shouldReturn('int'); + $this->__toString()->shouldReturn('int'); + } + + function it_prefix_namespace_with_antislash(): void + { + $this->beConstructedWith('Prophecy\\Doubler\\Generator\\Node\\Type\\SimpleType'); + $this->getType()->shouldReturn('\\Prophecy\\Doubler\\Generator\\Node\\Type\\SimpleType'); + $this->isBuiltin()->shouldReturn(false); + } + + function it_resolves_builtin_aliases(): void + { + $this->beConstructedWith('double'); + $this->getType()->shouldReturn('float'); + $this->isBuiltin()->shouldReturn(true); + } +} diff --git a/spec/Prophecy/Doubler/Generator/Node/Type/UnionTypeSpec.php b/spec/Prophecy/Doubler/Generator/Node/Type/UnionTypeSpec.php new file mode 100644 index 000000000..1c9553a0d --- /dev/null +++ b/spec/Prophecy/Doubler/Generator/Node/Type/UnionTypeSpec.php @@ -0,0 +1,82 @@ +beConstructedWith([ + new SimpleType('int'), + new SimpleType('string'), + ]); + } + function it_implements_type_interface(): void + { + $this->shouldImplement(TypeInterface::class); + } + + function it_throws_double_exception_when_union_type_given(): void + { + $this->beConstructedWith([ + new UnionType([new SimpleType('int'), new SimpleType('string')]), + new SimpleType('bool'), + ]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_throws_double_exception_when_types_duplicated(): void + { + $this->beConstructedWith([new SimpleType('string'), new SimpleType('string')]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_throws_double_exception_when_union_with_void(): void + { + $this->beConstructedWith([new SimpleType('void'), new SimpleType('string')]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_throws_double_exception_when_union_with_never(): void + { + $this->beConstructedWith([new SimpleType('never'), new SimpleType('string')]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_throws_double_exception_when_union_with_mixed(): void + { + $this->beConstructedWith([new SimpleType('mixed'), new SimpleType('string')]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_throws_double_exception_when_union_with_only_one_type(): void + { + $this->beConstructedWith([new SimpleType('string')]); + $this->shouldThrow(DoubleException::class)->duringInstantiation(); + } + + function it_return_array_of_its_types(): void + { + $this->getTypes()->shouldBeLike([ + new SimpleType('int'), + new SimpleType('string'), + ]); + } + + function it_should_accept_simple_type_and_intersection() + { + $type1 = new SimpleType('string'); + $type2 = new IntersectionType([new SimpleType('A'), new SimpleType('B')]); + $this->beConstructedWith([$type1, $type2]); + + $this->has($type1)->shouldBe(true); + $this->has($type2)->shouldBe(true); + } +} diff --git a/src/Prophecy/Doubler/Generator/ClassCodeGenerator.php b/src/Prophecy/Doubler/Generator/ClassCodeGenerator.php index 4d25a3d9c..289cd40e3 100644 --- a/src/Prophecy/Doubler/Generator/ClassCodeGenerator.php +++ b/src/Prophecy/Doubler/Generator/ClassCodeGenerator.php @@ -12,7 +12,12 @@ namespace Prophecy\Doubler\Generator; use Prophecy\Doubler\Generator\Node\ReturnTypeNode; +use Prophecy\Doubler\Generator\Node\Type\IntersectionType; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; +use Prophecy\Doubler\Generator\Node\Type\TypeInterface; +use Prophecy\Doubler\Generator\Node\Type\UnionType; use Prophecy\Doubler\Generator\Node\TypeNodeAbstract; +use Prophecy\Exception\Doubler\ClassCreatorException; /** * Class code creator. @@ -78,16 +83,36 @@ private function generateMethod(Node\MethodNode $method): string private function generateTypes(TypeNodeAbstract $typeNode): string { - if (!$typeNode->getTypes()) { + if ($typeNode->getType() === null) { return ''; } - // When we require PHP 8 we can stop generating ?foo nullables and remove this first block - if ($typeNode->canUseNullShorthand()) { - return sprintf('?%s', $typeNode->getNonNullTypes()[0]); - } else { - return join('|', $typeNode->getTypes()); + $generatedType = $this->generateSubType($typeNode->getType()); + + return $generatedType; + } + + private function generateSubType(TypeInterface $type): string + { + if ($type instanceof SimpleType) { + return $type->getType(); } + + if ($type instanceof UnionType) { + return join('|', array_map( + fn(TypeInterface $type) => $this->generateSubType($type), + $type->getTypes() + )); + } + + if ($type instanceof IntersectionType) { + return join('&', array_map( + fn(SimpleType $type) => $type->getType(), + $type->getTypes() + )); + } + + throw new ClassCreatorException(sprintf('Type "%s" is not supported.', get_class($type))); } /** diff --git a/src/Prophecy/Doubler/Generator/ClassMirror.php b/src/Prophecy/Doubler/Generator/ClassMirror.php index 12221746e..1f8219377 100644 --- a/src/Prophecy/Doubler/Generator/ClassMirror.php +++ b/src/Prophecy/Doubler/Generator/ClassMirror.php @@ -13,6 +13,10 @@ use Prophecy\Doubler\Generator\Node\ArgumentTypeNode; use Prophecy\Doubler\Generator\Node\ReturnTypeNode; +use Prophecy\Doubler\Generator\Node\Type\IntersectionType; +use Prophecy\Doubler\Generator\Node\Type\TypeInterface; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; +use Prophecy\Doubler\Generator\Node\Type\UnionType; use Prophecy\Exception\InvalidArgumentException; use Prophecy\Exception\Doubler\ClassMirrorException; use ReflectionClass; @@ -158,14 +162,22 @@ private function reflectMethodToNode(ReflectionMethod $method, Node\ClassNode $c $node->setReturnsReference(); } + $returnReflectionType = null; if ($method->hasReturnType()) { - \assert($method->getReturnType() !== null); - $returnTypes = $this->getTypeHints($method->getReturnType(), $method->getDeclaringClass(), $method->getReturnType()->allowsNull()); - $node->setReturnTypeNode(new ReturnTypeNode(...$returnTypes)); + $returnReflectionType = $method->getReturnType(); + \assert($returnReflectionType !== null); } elseif (method_exists($method, 'hasTentativeReturnType') && $method->hasTentativeReturnType()) { - \assert($method->getTentativeReturnType() !== null); - $returnTypes = $this->getTypeHints($method->getTentativeReturnType(), $method->getDeclaringClass(), $method->getTentativeReturnType()->allowsNull()); - $node->setReturnTypeNode(new ReturnTypeNode(...$returnTypes)); + // Tentative return types also need reflection + $returnReflectionType = $method->getTentativeReturnType(); + \assert($returnReflectionType !== null); + } + + if (null !== $returnReflectionType) { + $returnType = $this->createTypeFromReflection( + $returnReflectionType, + $method->getDeclaringClass() + ); + $node->setReturnTypeNode(new ReturnTypeNode($returnType)); } if (is_array($params = $method->getParameters()) && count($params)) { @@ -203,7 +215,6 @@ private function reflectArgumentToNode(ReflectionParameter $parameter, Reflectio $node->setAsPassedByReference(); } - $methodNode->addArgument($node); } @@ -232,6 +243,65 @@ private function getDefaultValue(ReflectionParameter $parameter) return $parameter->getDefaultValue(); } + /** + * @param ReflectionClass $declaringClass Context reflection class + */ + private function createTypeFromReflection(ReflectionType $type, ReflectionClass $declaringClass): TypeInterface + { + if ($type instanceof ReflectionIntersectionType) { + foreach ($type->getTypes() as $innerReflectionType) { + $innerTypes[] = new SimpleType($innerReflectionType->getName()); + } + return new IntersectionType($innerTypes); + } + + if ($type instanceof ReflectionUnionType) { + $innerTypes = []; + foreach ($type->getTypes() as $innerReflectionType) { + if ($innerReflectionType instanceof ReflectionIntersectionType) { + $innerTypes[] = $this->createTypeFromReflection($innerReflectionType, $declaringClass); + continue; + } + $name = $this->resolveTypeName($innerReflectionType->getName(), $declaringClass); + $innerTypes[] = new SimpleType($name); + } + // Nullability is handled by 'null' being one of the types in the union + return new UnionType($innerTypes); + } + + // Handle Named Types (single types like int, string, MyClass, ?MyClass) + if ($type instanceof ReflectionNamedType) { + $name = $this->resolveTypeName($type->getName(), $declaringClass); + $simpleType = new SimpleType($name); // SimpleType constructor normalizes + + // Handle nullability for named types explicitly by wrapping in a UnionType if needed + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return new UnionType([new SimpleType('null'), $simpleType]); + } + + return $simpleType; + } + + // Unknown ReflectionType implementation + throw new ClassMirrorException('Unknown reflection type: '.get_class($type), $declaringClass); + } + + private function resolveTypeName(string $name, ReflectionClass $contextClass): string + { + if ($name === 'self') { + return $contextClass->getName(); + } + if ($name === 'parent') { + $parent = $contextClass->getParentClass(); + if (false === $parent) { + throw new ClassMirrorException(sprintf('Cannot use "parent" type hint in class "%s" as it does not have a parent.', $contextClass->getName()), $contextClass); + } + return $parent->getName(); + } + + return $name; + } + /** * @param ReflectionClass $class * @@ -275,7 +345,7 @@ function (string $type) use ($class) { $types ); - if ($types && $types != ['mixed'] && $allowsNull) { + if ($types && $types != ['mixed'] && $allowsNull && !in_array('null', $types, true)) { $types[] = 'null'; } diff --git a/src/Prophecy/Doubler/Generator/Node/MethodNode.php b/src/Prophecy/Doubler/Generator/Node/MethodNode.php index a578adf9b..e2a048aa2 100644 --- a/src/Prophecy/Doubler/Generator/Node/MethodNode.php +++ b/src/Prophecy/Doubler/Generator/Node/MethodNode.php @@ -207,7 +207,7 @@ public function getReturnTypeNode(): ReturnTypeNode */ public function hasNullableReturnType() { - return $this->returnTypeNode->canUseNullShorthand(); + return $this->returnTypeNode->isNullable(); } /** diff --git a/src/Prophecy/Doubler/Generator/Node/ReturnTypeNode.php b/src/Prophecy/Doubler/Generator/Node/ReturnTypeNode.php index a731c9a27..daa012671 100644 --- a/src/Prophecy/Doubler/Generator/Node/ReturnTypeNode.php +++ b/src/Prophecy/Doubler/Generator/Node/ReturnTypeNode.php @@ -2,6 +2,7 @@ namespace Prophecy\Doubler\Generator\Node; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; use Prophecy\Exception\Doubler\DoubleException; final class ReturnTypeNode extends TypeNodeAbstract @@ -17,31 +18,27 @@ protected function getRealType(string $type): string } } - protected function guardIsValidType() - { - if (isset($this->types['void']) && count($this->types) !== 1) { - throw new DoubleException('void cannot be part of a union'); - } - if (isset($this->types['never']) && count($this->types) !== 1) { - throw new DoubleException('never cannot be part of a union'); - } - - parent::guardIsValidType(); - } - /** * @deprecated use hasReturnStatement * * @return bool */ - public function isVoid() + public function isVoid(): bool { - return $this->types == ['void' => 'void']; + if ($this->type === null) { + return true; + } + + return $this->type->equals(new SimpleType('void')); } public function hasReturnStatement(): bool { - return $this->types !== ['void' => 'void'] - && $this->types !== ['never' => 'never']; + if ($this->type === null) { + return true; + } + + return !$this->type->equals(new SimpleType('void')) + && !$this->type->equals(new SimpleType('never')); } } diff --git a/src/Prophecy/Doubler/Generator/Node/Type/IntersectionType.php b/src/Prophecy/Doubler/Generator/Node/Type/IntersectionType.php new file mode 100644 index 000000000..e70c51d33 --- /dev/null +++ b/src/Prophecy/Doubler/Generator/Node/Type/IntersectionType.php @@ -0,0 +1,67 @@ + $types + */ + public function __construct(private array $types) + { + $this->guard(); + } + + /** + * @return list + */ + public function getTypes(): array + { + return $this->types; + } + + private function has(SimpleType $givenType): bool + { + foreach ($this->types as $type) { + if ($type->equals($givenType)) { + return true; + } + } + + return false; + } + + public function equals(TypeInterface $givenType): bool + { + if (!$givenType instanceof IntersectionType) { + return false; + } + + if (count($this->types) !== count($givenType->getTypes())) { + return false; + } + + foreach ($this->types as $type) { + if (!$givenType->has($type)) { + return false; + } + } + + return true; + } + + private function guard(): void + { + // Cannot contain void, never, null, scalar types, mixed, union types etc. + foreach ($this->types as $type) { + if (!$type instanceof SimpleType || $type->isBuiltin()) { + throw new DoubleException('Intersection types can only contain class/interface names.'); + } + } + if (count($this->types) < 2) { + throw new DoubleException('Intersection types must contain at least two types.'); + } + } +} diff --git a/src/Prophecy/Doubler/Generator/Node/Type/SimpleType.php b/src/Prophecy/Doubler/Generator/Node/Type/SimpleType.php new file mode 100644 index 000000000..2629c701d --- /dev/null +++ b/src/Prophecy/Doubler/Generator/Node/Type/SimpleType.php @@ -0,0 +1,84 @@ +type = $this->normalizeType($type); + } + + public function isBuiltin(): bool + { + return $this->builtin; + } + + private function normalizeType(string $type): string + { + $this->builtin = true; + switch ($type) { + // type aliases + case 'double': + case 'real': + return 'float'; + case 'boolean': + return 'bool'; + case 'integer': + return 'int'; + + // built in types + case 'self': + case 'static': + case 'array': + case 'callable': + case 'bool': + case 'false': + case 'true': + case 'float': + case 'int': + case 'string': + case 'iterable': + case 'object': + case 'null': + case 'mixed': + case 'void': + case 'never': + return $type; + // Class / Interface type + default: + $this->builtin = false; + return $this->prefixWithNsSeparator($type); + } + } + + private function prefixWithNsSeparator(string $type): string + { + // Avoid double-prefixing if already prefixed + if (str_starts_with($type, '\\')) { + return $type; + } + return '\\'.$type; + } + + public function __toString(): string + { + return $this->getType(); + } + + public function getType(): string + { + return $this->type; + } + + public function equals(TypeInterface $givenType): bool + { + if (!$givenType instanceof SimpleType) { + return false; + } + + return $this->type === $givenType->getType(); + } +} diff --git a/src/Prophecy/Doubler/Generator/Node/Type/TypeInterface.php b/src/Prophecy/Doubler/Generator/Node/Type/TypeInterface.php new file mode 100644 index 000000000..9d92fff39 --- /dev/null +++ b/src/Prophecy/Doubler/Generator/Node/Type/TypeInterface.php @@ -0,0 +1,8 @@ + $types + */ + public function __construct(private array $types) + { + $this->guard(); + } + + /** + * @return list + */ + public function getTypes(): array + { + return $this->types; + } + + private function guard(): void + { + $typeCount = count($this->types); + + if ($typeCount < 2) { + // Throwing LogicException as this indicates misuse of the UnionType class itself. + throw new DoubleException(sprintf( + 'UnionType must be constructed with at least two types. Got %d.', + $typeCount + )); + } + + // To detect duplicates + $typeStrings = []; + + foreach ($this->types as $type) { + if ($type instanceof UnionType) { + throw new DoubleException('Union types cannot contain other unions.'); + } + if ($type instanceof IntersectionType) { + $typeStrings[] = implode('inter-', array_map(fn(SimpleType $type) => $type->getType(), $type->getTypes())); + continue; // Valid type, nothing to be checked + } + if (!$type instanceof SimpleType) { + throw new DoubleException(sprintf('Unexpected type "%s". Only IntersectionType and SimpleType are supported in UnionType.', get_class($type))); + } + $typeName = $type->getType(); + $typeStrings[] = $typeName; + + if (in_array($typeName, ['void', 'never', 'mixed'], true)) { + throw new DoubleException(sprintf('Type "%s" cannot be part of a union type.', $typeName)); + } + } + + // Rule: Union types cannot contain duplicate types (e.g., int|string|int is invalid). + // Reflection usually resolves this, but it's good practice to ensure consistency. + if (count(array_unique($typeStrings)) !== $typeCount) { + throw new DoubleException(sprintf( + 'Union types cannot contain duplicate types. Found duplicates in: %s', + implode('|', $typeStrings) + )); + } + } + + public function has(SimpleType|IntersectionType $givenType): bool + { + foreach ($this->types as $type) { + if ($type->equals($givenType)) { + return true; + } + } + + return false; + } + + public function equals(TypeInterface $givenType): bool + { + if (!$givenType instanceof UnionType) { + return false; + } + + if (count($this->types) !== count($givenType->getTypes())) { + return false; + } + + foreach ($this->types as $type) { + if (!$givenType->has($type)) { + return false; + } + } + } +} diff --git a/src/Prophecy/Doubler/Generator/Node/TypeNodeAbstract.php b/src/Prophecy/Doubler/Generator/Node/TypeNodeAbstract.php index ebca52337..2277f9a78 100644 --- a/src/Prophecy/Doubler/Generator/Node/TypeNodeAbstract.php +++ b/src/Prophecy/Doubler/Generator/Node/TypeNodeAbstract.php @@ -2,45 +2,140 @@ namespace Prophecy\Doubler\Generator\Node; +use Prophecy\Doubler\Generator\Node\Type\IntersectionType; +use Prophecy\Doubler\Generator\Node\Type\TypeInterface; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; +use Prophecy\Doubler\Generator\Node\Type\UnionType; use Prophecy\Exception\Doubler\DoubleException; abstract class TypeNodeAbstract { - /** @var array */ - protected $types = []; + protected TypeInterface|null $type; - public function __construct(string ...$types) + /** + * @param string|TypeInterface ...$types + */ + public function __construct(string|TypeInterface ...$types) { - foreach ($types as $type) { - $type = $this->getRealType($type); - $this->types[$type] = $type; + $deprecation = 'Only 1 type will be supported in the future, strings are no longer supported as type.'; + if (count($types) !== 1) { + // TODO: trigger deprecation notice + } else { + foreach ($types as $type) { + if (!$type instanceof TypeInterface) { + // TODO: deprecation notice + break; + } + } } - $this->guardIsValidType(); + // BC Layer for usage with strings + $typesNormalized = []; + $union = []; + foreach ($types as $index => $type) { + if (is_string($type)) { + $type = new SimpleType($type); + if (!in_array($type->getType(), $typesNormalized, true)) { + $union[] = $type; + $typesNormalized[] = $type->getType(); + } + continue; + } + $union[] = $type; + } + + // BC Layer for usage with many types + if (count($union) > 1) { + $this->type = new UnionType($union); + } else { + $this->type = $union[0] ?? null; + } } + /** + * @deprecated use nullable() instead + */ public function canUseNullShorthand(): bool { - return isset($this->types['null']) && count($this->types) === 2; + if ($this->type instanceof UnionType) { + return $this->type->has(new SimpleType('null')) && count($this->type->getTypes()) === 2; + } + + return false; + } + + public function isNullable() + { + if ($this->type instanceof UnionType) { + return $this->type->has(new SimpleType('null')); + } + + if ($this->type instanceof SimpleType && $this->type->getType() === 'null') { + return true; + } + + return false; } /** * @return list + * @deprecated use getType() instead */ public function getTypes(): array { - return array_values($this->types); + // TODO: add deprecation notice + if ($this->type instanceof SimpleType) { + return [$this->type->getType()]; + } + + $types = []; + + if ($this->type instanceof UnionType) { + foreach ($this->type->getTypes() as $type) { + if ($type instanceof IntersectionType) { + throw new DoubleException('getType() method is deprecated and do not support IntersectionType by design. Use getType() instead.'); + } + $types[$type->getType()] = $type->getType(); + } + } + + return array_values($types); + } + + public function getType(): ?TypeInterface + { + return $this->type; } /** + * @deprecated use getType() instead * @return list */ public function getNonNullTypes(): array { - $nonNullTypes = $this->types; - unset($nonNullTypes['null']); + if ($this->type === null) { + return []; + } + if ($this->type instanceof UnionType) { + $types = []; + foreach ($this->type->getTypes() as $type) { + if ($type->getType() === 'null') { + continue; + } + $types[] = $type->getType(); + } + + return $types; + } - return array_values($nonNullTypes); + if ($this->type instanceof SimpleType) { + if ($this->type->getType() === 'null') { + return []; + } + return [$this->type->getType()]; + } + + throw new DoubleException('getNonNullTypes() method is deprecated and do not support IntersectionType by design. Use getType() instead.'); } protected function prefixWithNsSeparator(string $type): string @@ -75,20 +170,12 @@ protected function getRealType(string $type): string case 'object': case 'null': case 'mixed': + case 'void': + case 'never': return $type; - default: + // Class / Interface type return $this->prefixWithNsSeparator($type); } } - - /** - * @return void - */ - protected function guardIsValidType() - { - if (isset($this->types['mixed']) && count($this->types) !== 1) { - throw new DoubleException('mixed cannot be part of a union'); - } - } } diff --git a/src/Prophecy/Prophecy/ObjectProphecy.php b/src/Prophecy/Prophecy/ObjectProphecy.php index e59b3eb3a..df6e945a2 100644 --- a/src/Prophecy/Prophecy/ObjectProphecy.php +++ b/src/Prophecy/Prophecy/ObjectProphecy.php @@ -148,7 +148,7 @@ public function addMethodProphecy(MethodProphecy $methodProphecy) $methodName = strtolower($methodProphecy->getMethodName()); if (!isset($this->methodProphecies[$methodName])) { - $this->methodProphecies[$methodName] = array(); + $this->methodProphecies[$methodName] = []; } $this->methodProphecies[$methodName][] = $methodProphecy; @@ -172,7 +172,7 @@ public function getMethodProphecies($methodName = null) $methodName = strtolower($methodName); if (!isset($this->methodProphecies[$methodName])) { - return array(); + return []; } return $this->methodProphecies[$methodName]; diff --git a/tests/Doubler/Generator/ClassMirrorTest.php b/tests/Doubler/Generator/ClassMirrorTest.php index 80d41dc2c..1361e30dc 100644 --- a/tests/Doubler/Generator/ClassMirrorTest.php +++ b/tests/Doubler/Generator/ClassMirrorTest.php @@ -8,6 +8,9 @@ use Prophecy\Doubler\Generator\ClassMirror; use Prophecy\Doubler\Generator\Node\ArgumentTypeNode; use Prophecy\Doubler\Generator\Node\ReturnTypeNode; +use Prophecy\Doubler\Generator\Node\Type\IntersectionType; +use Prophecy\Doubler\Generator\Node\Type\SimpleType; +use Prophecy\Doubler\Generator\Node\Type\UnionType; use Prophecy\Exception\Doubler\ClassMirrorException; use Prophecy\Exception\InvalidArgumentException; use Prophecy\Prophet; @@ -493,7 +496,7 @@ public function it_can_double_a_class_with_union_argument_types(): void $classNode = (new ClassMirror())->reflect(new \ReflectionClass('Fixtures\Prophecy\UnionArgumentTypes'), []); $methodNode = $classNode->getMethods()['doSomething']; - $this->assertEquals(new ArgumentTypeNode('bool', '\\stdClass'), $methodNode->getArguments()[0]->getTypeNode()); + $this->assertEquals(new ArgumentTypeNode('\\stdClass', 'bool'), $methodNode->getArguments()[0]->getTypeNode()); } #[Test] @@ -506,7 +509,7 @@ public function it_can_double_a_class_with_union_argument_type_with_false(): voi $classNode = (new ClassMirror())->reflect(new \ReflectionClass('Fixtures\Prophecy\UnionArgumentTypeFalse'), []); $methodNode = $classNode->getMethods()['method']; - $this->assertEquals(new ArgumentTypeNode('false', '\stdClass'), $methodNode->getArguments()[0]->getTypeNode()); + $this->assertEquals(new ArgumentTypeNode('\stdClass', 'false'), $methodNode->getArguments()[0]->getTypeNode()); } #[Test] @@ -559,15 +562,24 @@ public function it_can_not_double_an_enum(): void } #[Test] - public function it_can_not_double_intersection_return_types(): void + public function it_can_double_intersection_return_types(): void { if (PHP_VERSION_ID < 80100) { $this->markTestSkipped('Intersection types are not supported in this PHP version'); } - $this->expectException(ClassMirrorException::class); - $classNode = (new ClassMirror())->reflect(new \ReflectionClass('Fixtures\Prophecy\IntersectionReturnType'), []); + + $method = $classNode->getMethod('doSomething'); + $returnType = $method->getReturnTypeNode(); + + $this->assertEquals( + new IntersectionType([ + new SimpleType('\Fixtures\Prophecy\Bar'), + new SimpleType('\Fixtures\Prophecy\Baz'), + ]), + $returnType->getType() + ); } #[Test] @@ -633,7 +645,7 @@ public function it_can_double_a_nullable_parameter_type_of_false(): void $method = $classNode->getMethod('method'); $arguments = $method->getArguments(); - $this->assertEquals(new ArgumentTypeNode('null', 'false'), $arguments[0]->getTypeNode()); + $this->assertEquals(new ArgumentTypeNode('false', 'null'), $arguments[0]->getTypeNode()); } #[Test] @@ -649,15 +661,23 @@ public function it_can_not_double_dnf_intersection_argument_types(): void } #[Test] - public function it_can_not_double_dnf_intersection_return_types(): void + public function it_can_double_dnf_intersection_return_types(): void { - if (PHP_VERSION_ID < 80200) { - $this->markTestSkipped('DNF intersection types are not supported in this PHP version'); - } - - $this->expectException(ClassMirrorException::class); - $classNode = (new ClassMirror())->reflect(new \ReflectionClass('Fixtures\Prophecy\DnfReturnType'), []); + + $method = $classNode->getMethod('doSomething'); + $returnType = $method->getReturnTypeNode(); + + $this->assertEquals( + new UnionType([ + new IntersectionType([ + new SimpleType('\Fixtures\Prophecy\A'), + new SimpleType('\Fixtures\Prophecy\B'), + ]), + new SimpleType('\Fixtures\Prophecy\C'), + ]), + $returnType->getType() + ); } #[Test] @@ -722,7 +742,7 @@ public function it_can_double_a_nullable_parameter_type_of_true(): void $method = $classNode->getMethod('method'); $arguments = $method->getArguments(); - $this->assertEquals(new ArgumentTypeNode('null', 'true'), $arguments[0]->getTypeNode()); + $this->assertEquals(new ArgumentTypeNode('true', 'null'), $arguments[0]->getTypeNode()); } #[Test]