diff --git a/phpunit/code/inheritance_error_return_intersection_missing.php b/phpunit/code/inheritance_error_return_intersection_missing.php new file mode 100644 index 00000000..03ac56c2 --- /dev/null +++ b/phpunit/code/inheritance_error_return_intersection_missing.php @@ -0,0 +1,23 @@ +exec('must be compatible', 'inheritance_error_return_contravariant_class.php'); } + public function testUnionReturnTypeCannotBeWidenedToUnrelatedType(): void + { + $this->exec('must be compatible', 'inheritance_error_return_union_widened.php'); + } + + public function testIntersectionReturnTypeCannotDropAMember(): void + { + $this->exec('must be compatible', 'inheritance_error_return_intersection_missing.php'); + } + + public function testStaticReturnTypeCannotBeWidenedToSelf(): void + { + $this->exec('must be compatible', 'inheritance_error_return_static_widened.php'); + } + + public function testNeverReturnTypeCannotBeWidenedToVoid(): void + { + $this->exec('must be compatible', 'inheritance_error_return_never_widened.php'); + } + + public function testIntersectionReturnTypeCanNarrowToIntersectionOrConcreteSubtype(): void + { + $this->assertCompiles('return_type_covariance_intersection.php'); + } + public function testParameterTypeCannotBeCovariant() { $this->exec('must be compatible', 'inheritance_error_param_covariant_class.php'); diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index 292967a1..66dbb2e2 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -116,7 +116,7 @@ trait TypeCheckGenerator return $class ? [['kind' => 'instanceof', 'class' => $class]] : []; } - private function typeCheckNodeToString(NodeAbstract $typeNode): string + protected function typeCheckNodeToString(NodeAbstract $typeNode): string { if ($typeNode instanceof Node\Identifier) { return $typeNode->name; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 2d311e00..98e2bcec 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -542,6 +542,9 @@ class Preprocessor extends CompilerBase } $functionDef->exported = !($this->classDef?->exported === false || $this->hasNoExportAttribute($v)); $functionDef->returnClass = $class; + $functionDef->returnTypeStr = $v->returnType === null + ? '' + : $this->typeCheckNodeToString($v->returnType); // Record late-bound return type keywords so they can be re-resolved to // the consuming class when a trait method is flattened into a class. $functionDef->returnTypeKeyword = $returnTypeKeyword; @@ -560,7 +563,6 @@ class Preprocessor extends CompilerBase $typeInfo = $this->buildTypeCheckFromNode($v->returnType); if (!empty($typeInfo['check'])) { $functionDef->returnTypeCheck = $typeInfo['check']; - $functionDef->returnTypeStr = $typeInfo['typeStr']; $functionDef->returnTypeNode = $v->returnType; } } diff --git a/src/Translator.php b/src/Translator.php index 8d36035f..09ac076d 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3624,7 +3624,12 @@ CODE; )); } - if (!$this->isReturnTypeOverrideCompatible($childFuncDef, $parentFuncDef)) { + if (!$this->isReturnTypeOverrideCompatible( + $childFuncDef, + $parentFuncDef, + $className, + $parentClass, + )) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } if ($childFuncDef->returnsByRef !== $parentFuncDef->returnsByRef) { @@ -3709,143 +3714,163 @@ CODE; )); } - private function isReturnTypeOverrideCompatible(FunctionDef $childFuncDef, FunctionDef $parentFuncDef): bool - { + private function isReturnTypeOverrideCompatible( + FunctionDef $childFuncDef, + FunctionDef $parentFuncDef, + string $childClass, + string $parentClass, + ): bool { if ($parentFuncDef->returnTypeUndeclared) { return true; } if ($childFuncDef->returnTypeUndeclared) { return false; } - // A parent that accepts everything (mixed/var) is compatible with any - // child return type. - if ($parentFuncDef->returnType === Type::VAR) { - return true; - } - $parentTypes = $this->getReturnAcceptedTypes($parentFuncDef); - $childTypes = $this->getReturnAcceptedTypes($childFuncDef); + $parentTypes = $this->getReturnAcceptedTypes($parentFuncDef, $parentClass); + $childTypes = $this->getReturnAcceptedTypes($childFuncDef, $childClass); - // Return type covariance: every value the child can return must also be - // acceptable under the parent's declared return type. This allows a - // child to narrow a nullable/union return type (e.g. `?Base` -> `?Child` - // or `int|string` -> `int`) while still satisfying the parent contract. - return $this->isReturnTypeSubtype($childTypes, $parentTypes); + // Type checks are stored in disjunctive normal form: the outer list is + // a union, while an allOf entry is an intersection. Every child union + // branch must imply at least one complete parent branch. + foreach ($childTypes as $childType) { + if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) { + return false; + } + } + return true; } - private function getReturnAcceptedTypes(FunctionDef $functionDef): array + private function getReturnAcceptedTypes(FunctionDef $functionDef, string $declaringClass): array { if (!empty($functionDef->returnTypeCheck)) { - return $functionDef->returnTypeCheck; - } - $type = $functionDef->returnType; - if ($type === Type::VAR) { - return [['kind' => 'isMixed']]; - } - if ($type === Type::OBJECT) { - return $functionDef->returnClass - ? [['kind' => 'instanceof', 'class' => $functionDef->returnClass]] - : [['kind' => 'isObject']]; + return array_map( + fn (array $type): array => $this->normalizeReturnTypeEntry($type, $declaringClass), + $functionDef->returnTypeCheck, + ); } - return match ($type) { - Type::INT => [['kind' => 'isInt']], - Type::FLOAT => [['kind' => 'isFloat']], - Type::BOOL => [['kind' => 'isBool']], - Type::STR => [['kind' => 'isString']], - Type::ARRAY => [['kind' => 'isArray']], - Type::RESOURCE => [['kind' => 'isResource']], - default => [['kind' => 'isMixed']], + + if ($functionDef->returnTypeKeyword === 'static') { + return [['kind' => 'isStatic', 'class' => $declaringClass]]; + } + if ($functionDef->returnType === Type::OBJECT && $functionDef->returnClass !== '') { + return [['kind' => 'instanceof', 'class' => $functionDef->returnClass]]; + } + + $declaredType = strtolower($functionDef->returnTypeStr); + return match ($declaredType) { + 'mixed' => [['kind' => 'isMixed']], + 'never' => [['kind' => 'isNever']], + 'void' => [['kind' => 'isVoid']], + 'null' => [['kind' => 'isNull']], + 'true' => [['kind' => 'isTrue']], + 'false' => [['kind' => 'isFalse']], + 'callable' => [['kind' => 'callable']], + 'iterable' => [['kind' => 'iterable']], + 'object' => [['kind' => 'isObject']], + default => match ($functionDef->returnType) { + Type::INT => [['kind' => 'isInt']], + Type::FLOAT => [['kind' => 'isFloat']], + Type::BOOL => [['kind' => 'isBool']], + Type::STR => [['kind' => 'isString']], + Type::ARRAY => [['kind' => 'isArray']], + Type::RESOURCE => [['kind' => 'isResource']], + Type::OBJECT => [['kind' => 'isObject']], + default => [['kind' => 'isMixed']], + }, }; } - private function isReturnTypeSubtype(array $childTypes, array $parentTypes): bool + private function normalizeReturnTypeEntry(array $type, string $declaringClass): array { - foreach ($childTypes as $childType) { - if (!$this->isReturnTypeCoveredBy($childType, $parentTypes)) { - return false; - } + if (($type['kind'] ?? null) === 'allOf') { + $type['types'] = array_map( + fn (array $member): array => $this->normalizeReturnTypeEntry($member, $declaringClass), + $type['types'], + ); + } elseif (($type['kind'] ?? null) === 'instanceof' && ($type['class'] ?? null) === 'static') { + $type = ['kind' => 'isStatic', 'class' => $declaringClass]; } - return true; + return $type; } private function isReturnTypeCoveredBy(array $childType, array $parentTypes): bool { - $childKind = $childType['kind'] ?? null; + $childClause = ($childType['kind'] ?? null) === 'allOf' + ? $childType['types'] + : [$childType]; - // Child is an intersection (A&B): it is a subtype only if every member - // is individually a subtype of the parent type. - if ($childKind === 'allOf') { - foreach ($childType['types'] as $member) { - if (!$this->isReturnTypeCoveredBy($member, $parentTypes)) { - return false; - } + foreach ($parentTypes as $parentType) { + $parentClause = ($parentType['kind'] ?? null) === 'allOf' + ? $parentType['types'] + : [$parentType]; + if ($this->isReturnTypeClauseSubtype($childClause, $parentClause)) { + return true; } - return true; } + return false; + } - foreach ($parentTypes as $parentType) { - $parentKind = $parentType['kind'] ?? null; - - // Parent is an intersection (A&B): the child must be a subtype of - // every member of the intersection. - if ($parentKind === 'allOf') { - $ok = true; - foreach ($parentType['types'] as $member) { - if (!$this->isReturnTypeCoveredBy($childType, [$member])) { - $ok = false; - break; - } - } - if ($ok) { - return true; + private function isReturnTypeClauseSubtype(array $childClause, array $parentClause): bool + { + foreach ($parentClause as $parentType) { + $covered = false; + foreach ($childClause as $childType) { + if ($this->isReturnTypeEntryCompatible($childType, $parentType)) { + $covered = true; + break; } - continue; } - - if ($this->isReturnTypeEntryCompatible($childKind, $childType, $parentKind, $parentType)) { - return true; + if (!$covered) { + return false; } } - - return false; + return true; } - private function isReturnTypeEntryCompatible( - ?string $childKind, - array $childType, - ?string $parentKind, - array $parentType - ): bool { - if ($childKind === 'isNull') { - // A null value is only compatible with a nullable (isNull) parent. - return $parentKind === 'isNull'; + private function isReturnTypeEntryCompatible(array $childType, array $parentType): bool + { + $childKind = $childType['kind'] ?? null; + $parentKind = $parentType['kind'] ?? null; + + if ($childKind === 'isNever' || $parentKind === 'isMixed') { + return true; } - if ($childKind === 'isObject') { - // Any object is compatible with a parent that accepts any object. - return $parentKind === 'isObject'; + if (($childKind === 'isTrue' || $childKind === 'isFalse') && $parentKind === 'isBool') { + return true; } - if ($childKind === 'isMixed') { - return $parentKind === 'isMixed'; + if ($childKind === 'isArray' && $parentKind === 'iterable') { + return true; + } + if ($childKind === 'isStatic') { + if ($parentKind === 'isObject' || $parentKind === 'isStatic') { + return true; + } + if ($parentKind === 'instanceof') { + return $this->isInheritedFrom( + $childType['class'] ?? '', + $parentType['class'] ?? '', + ); + } + return false; } if ($childKind === 'instanceof') { if ($parentKind === 'isObject') { return true; } + $childClass = $childType['class'] ?? ''; + if ($parentKind === 'iterable') { + return $childClass !== '' && $this->isInheritedFrom($childClass, 'Traversable'); + } if ($parentKind === 'instanceof') { - $childClass = $childType['class'] ?? ''; $parentClass = $parentType['class'] ?? ''; - if ($childClass === '' || $parentClass === '' || $childClass === 'static' || $parentClass === 'static') { - return false; - } - if ($childClass === $parentClass) { - return true; - } - return $this->isInheritedFrom($childClass, $parentClass); + return $childClass !== '' + && $parentClass !== '' + && $this->isInheritedFrom($childClass, $parentClass); } return false; } - // Scalar kinds must match exactly. - return $childKind === $parentKind; + return $childKind !== null && $childKind === $parentKind; } private function isParameterTypeOverrideCompatible(ArgInfo $childArg, ArgInfo $parentArg): bool diff --git a/tests/compiler/type_decl/return-type-covariance.phpt b/tests/compiler/type_decl/return-type-covariance.phpt index 3de3e6bb..68883ac3 100644 --- a/tests/compiler/type_decl/return-type-covariance.phpt +++ b/tests/compiler/type_decl/return-type-covariance.phpt @@ -34,6 +34,61 @@ class ObjectReturnImpl implements ObjectReturnContract } } +class StaticBase +{ + public function copy(): ?self + { + return $this; + } +} + +class StaticChild extends StaticBase +{ + public function copy(): ?static + { + return $this; + } +} + +interface IterableContract +{ + public function values(): iterable; +} + +class IterableImpl implements IterableContract +{ + public function values(): array + { + return [1, 2]; + } +} + +interface BoolContract +{ + public function enabled(): bool; +} + +class LiteralBoolImpl implements BoolContract +{ + public function enabled(): true + { + return true; + } +} + +abstract class VoidContract +{ + abstract public function stop(): void; +} + +abstract class NeverImpl extends VoidContract +{ + public function stop(): never + { + throw new RuntimeException('stop'); + } +} + function main() { $impl = new UnionReturnImpl(); @@ -43,9 +98,23 @@ function main() $built = $obj->build(); var_dump($built instanceof BaseType); var_dump($built instanceof ChildType); + + $static = new StaticChild(); + var_dump($static->copy() instanceof StaticChild); + + var_dump((new IterableImpl())->values()); + var_dump((new LiteralBoolImpl())->enabled()); } ?> --EXPECT-- int(42) bool(true) bool(true) +bool(true) +array(2) { + [0]=> + int(1) + [1]=> + int(2) +} +bool(true)