-
Notifications
You must be signed in to change notification settings - Fork 242
Intersection types #637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Intersection types #637
Conversation
$node->setReturnTypeNode(new ReturnTypeNode(...$returnTypes)); | ||
// Tentative return types also need reflection | ||
$returnReflectionType = $method->getTentativeReturnType(); | ||
\assert($returnReflectionType !== null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think those asserts are useless (the $returnReflectionType
variable will still be nullable anyway as there is no else
branch assigning it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please remove those 2 useless asserts (as the $returnReflectionType
variable is not used anymore in those if and elseif branches, but only later where we have to deal with null
anyway)
{ | ||
if ($type instanceof ReflectionIntersectionType) { | ||
foreach ($type->getTypes() as $innerReflectionType) { | ||
$innerTypes[] = new SimpleType($innerReflectionType->getName()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isn't this missing the resolution of the type name ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe this should use createTypeFromReflection
for the inner type as well.
return $this->types !== ['void' => 'void'] | ||
&& $this->types !== ['never' => 'never']; | ||
if ($this->type === null) { | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should be true
/** | ||
* @param string|TypeInterface ...$types | ||
*/ | ||
public function __construct(string|TypeInterface ...$types) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't the new API allows null
as well ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep the new API will be null|string|TypeInterface $type = null
, you're absolutely right
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then this needs to be updated to support passing null
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My bad it should be new BuiltinType('null')
in the new API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not talking about the case where of using null
as return type, but of the case of not having a return type at all.
} | ||
|
||
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the union contains an intersection type as its other type ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not see any problem here but code generation needs an update for sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah true. We can use the null shorthand on an intersection. I forgot that.
throw new DoubleException('Type cannot be nullable true'); | ||
} | ||
} | ||
|
||
if (\PHP_VERSION_ID >= 80000 && isset($this->types['mixed']) && count($this->types) !== 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why removing this ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question is more like why is this method guardIsValidType
still here. I will remove it on cleanup. The UnionType checks this mixed-thing already.
return $this->prefixWithNsSeparator($type); | ||
} | ||
} | ||
|
||
/** | ||
* @todo: put this in SimpleType |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This cannot really go in SimpleType, as some of those are about the full type, not each simple type used in a union
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess each type will have its own exceptions, this still needs to be done.
return $this->prefixWithNsSeparator($type); | ||
} | ||
} | ||
|
||
/** | ||
* @todo: put this in SimpleType | ||
* @return void | ||
*/ | ||
protected function guardIsValidType() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Be careful. ReturnTypeNode extends this class to override this method, but you still use $this->types
in it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After cleanup this method should not even exist anymore here.
|
||
namespace Prophecy\Doubler\Generator\Node\Type; | ||
|
||
class SimpleType implements TypeInterface, \Stringable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think TypeInterface should actually extend Stringable to force all types to be castable to string (and documenting that we expect their string representation to be usable in a namespaced context)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had this idea at the begining but I ended up with this because I don't want to make code generation in tostring methods. Actually I think I will remove stringable even from here.
return 'int'; | ||
|
||
// built in types | ||
case 'self': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self
(and parent
) should be resolved to the actual class name before creating the type from reflection, to avoid issues (Prophecy will use those types to generate methods in a child class, where those keywords would have a different meaning, which would break in parameter types)
5250a7c
to
ebb7746
Compare
Thank you for your review @stof . And good news! I fixed the bugs in my previous implementation. (some you noticed in your review!) Keeping backward compatibility has been a real pain! But it WORKS. (test suite green 100%) It's currently a fully working PR for intersection type.... But only for return types! Some work needs to be done for cleaning... But it also needs php to be bumped to 8.1+ (which would probably be better in another PR), and a lot of cleaning. But for now, since it's a lot of work and personal investment, I'd like you to tell me if the implementation is ok before working on it again. |
e0672c9
to
facea20
Compare
|
||
namespace Prophecy\Doubler\Generator\Node\Type; | ||
|
||
class SimpleType implements TypeInterface, \Stringable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To simplify code generation, I think we should have 2 separate Type classes for that:
- BuiltinType to represent built-in types
- ObjectType to represent object-based types, taking a class name as argument (and prefixing it with a
\
when rendering it to be compatible with namespaced context, but not when returning it in agetClass
method so that we can compare that getter toFoo::class
if needed, unlike the currentgetType
method)
Both classes can of course implement a SimpleType interface containing a method returning the code representation of the type. But then, I think it could make sense to have that method returning such code representation be part of TypeInterface and let UnionType and IntersectionType implement it (basically, using the logic you have in the ClassCodeGenerator for now)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having an ObjectType and BuiltinType looks over-engineered to me (a lot of code for basically the same thing). It also involve a factory in the middle making the distinction between the 2 cases (in classmirror).
About the problem you mention, it would be possible to normalize the type only in the toString method, isn't it?
I understand that having the 2 types makes sense. I actually like pretty much the idea, I just worry of the "why" and "isn't it just more code for more code".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ClassMirror could create the right type based on ReflectionNamedType::isBuiltIn()
, which would also mean we automatically get support for new builtin types for newer PHP versions instead of having to hardcode which strings are a built-in type that need to produce a different code representation (classes need to be prefixed with \
to be used in a namespaced context while built-in type must not be prefixed).
use Prophecy\Exception\Doubler\DoubleException; | ||
|
||
abstract class TypeNodeAbstract | ||
{ | ||
/** @var array<string, string> */ | ||
protected $types = []; | ||
protected TypeInterface|null $type; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest making the new property private (if child classes need to read it, there is a public getter they can use)
/** | ||
* @param string|TypeInterface ...$types | ||
*/ | ||
public function __construct(string|TypeInterface ...$types) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then this needs to be updated to support passing null
} | ||
|
||
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah true. We can use the null shorthand on an intersection. I forgot that.
|
||
if ($type instanceof UnionType) { | ||
return join('|', array_map( | ||
fn (TypeInterface $type) => $this->generateSubType($type), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this logic does not support DNF types. Parenthesis are required around the intersection types used in a union type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, and fixed!
- Introduce new objects ObjectType and BuiltinType - Fix implementation for self and static types - Add final tags - Fix some phpstan warnings
@stof I updated the PR following your insights, thank you for the detailed review. ⭐ I still need to track deprecated calls and add deprecation triggers, and I will do it promptly as soon as you validate that new implementation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The general approach looks good. I still recommend changing the way we manage the source code representation of a given type.
Please start working on the deprecation layer as well.
@@ -33,13 +33,6 @@ function it_can_have_void_type() | |||
$this->getTypes()->shouldReturn(['void']); | |||
} | |||
|
|||
function it_will_normalise_type_aliases_types() | |||
{ | |||
$this->beConstructedWith('double', 'real', 'boolean', 'integer'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be kept as a legacy test, as the legacy API must keep normalizing them to avoid BC breaks.
$node->setReturnTypeNode(new ReturnTypeNode(...$returnTypes)); | ||
// Tentative return types also need reflection | ||
$returnReflectionType = $method->getTentativeReturnType(); | ||
\assert($returnReflectionType !== null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please remove those 2 useless asserts (as the $returnReflectionType
variable is not used anymore in those if and elseif branches, but only later where we have to deal with null
anyway)
// Intersections cannot be composed of builtin types | ||
$innerTypes[] = new ObjectType($innerReflectionType->getName()); | ||
} | ||
return new IntersectionType($innerTypes); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what about ?Foo&Bar
? This is something that can exist (it came earlier than DNF types). It would be great to add a test covering such nullable intersection types in the ClassMirrorTest.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hum it does not seem to be supported with the current implementation. I will add a test for it. Nice catch!
$name = $this->resolveTypeName($type->getName(), $declaringClass); | ||
if ($type->isBuiltin() || $name === 'static') { | ||
$simpleType = new BuiltinType($name); // SimpleType constructor normalizes | ||
} elseif ($name === 'self') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveTypeName
already handles the case of self
$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 && $types[0] instanceof TypeInterface) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should accept count($types) === 1 && $types[0] === null
as well, to assign null
in the property
case 'integer': | ||
return 'int'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to keep this normalization logic for BC reasons
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree but I misinterpreted one of your previous comment. I will change this back.
namespace Prophecy\Doubler\Generator\Node\Type; | ||
|
||
/** | ||
* @final |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make it actually final ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if this is only about the mocking in specs, they should use actual instances, as those type classes are immutable value objects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's also for its own spec
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think so. phpspec supports specing final classes AFAIK.
|
||
private function prefixWithNsSeparator(string $type): string | ||
{ | ||
// Avoid double-prefixing if already prefixed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the new API should not deal with that, expecting the unprefixed class name to be passed instead (which might be guarded to avoid issues). The BC layer in TypeNodeAbstract would have to handle the BC layer.
{ | ||
public function __construct(string $type) | ||
{ | ||
parent::__construct($this->prefixWithNsSeparator($type)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest separating the string representation from getType
in ObjectType:
- FQCN in PHP are not starting with
\
, so I would find it good forObjectType->getType()
to return the unprefixed one (allowing to do things like$objectType->getType() === SplFileInfo::class
) - qualified usages in PHP are starting with a
\
(to resolve them as absolute names rather than relative names), so we need a\
prefix in the__toString
method returning the source code representation (usable in namespaced context)
For that, I suggest that SimpleType becomes an interface (extending TypeInterface) instead of an abstract class sharing implementation.
return $generatedType; | ||
} | ||
|
||
private function generateSubType(TypeInterface $type, bool $isDnf = false): string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still vote for making TypeInterface
extends Stringable, expecting TypeInterface::__toString
to provide the source code representation of the type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I will refactor this
Please explain what you mean because I refactored precisely with your recommandation here (the "ObjectType" vs "BuiltinType" thing in particular) |
@Nek- that's what I detailed more in #637 (comment) and #637 (comment) |
@@ -559,15 +563,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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like those version checks were not properly cleaned when bumping the min PHP version.
Note about the design: I created a new sub-api for type management because the current one is not compatible with intersections but needs to exist to keep backward compatibility.
What still needs to be done:
Please have a look and tell me if this kind of implementation would be ok for you. Thanks!
Stands as replacement for #569 and should fix #535 and #558