diff --git a/phpunit/code/type_rule_bool_false.php b/phpunit/code/type_rule_bool_false.php new file mode 100644 index 00000000..8551de0a --- /dev/null +++ b/phpunit/code/type_rule_bool_false.php @@ -0,0 +1,4 @@ +exec("Type 'self' cannot be part of an intersection type", 'intersection_type_self_not_allowed.php'); + $this->exec('Type `self` cannot be part of an intersection type', 'intersection_type_self_not_allowed.php'); } public function testParentCannotBePartOfIntersectionType() { - $this->exec("Type 'parent' cannot be part of an intersection type", 'intersection_type_parent_not_allowed.php'); + $this->exec('Type `parent` cannot be part of an intersection type', 'intersection_type_parent_not_allowed.php'); } public function testStaticCannotBePartOfIntersectionType() { - $this->exec("Type 'static' cannot be part of an intersection type", 'intersection_type_static_not_allowed.php'); + $this->exec('Type `static` cannot be part of an intersection type', 'intersection_type_static_not_allowed.php'); } public function testConstructorCannotDeclareReturnType() diff --git a/phpunit/src/CompoundTypeValidationTest.php b/phpunit/src/CompoundTypeValidationTest.php new file mode 100644 index 00000000..b8790fa3 --- /dev/null +++ b/phpunit/src/CompoundTypeValidationTest.php @@ -0,0 +1,232 @@ +exec('Duplicate type `int` is redundant', 'type_rule_dup_union.php'); + } + + public function testDuplicateClassUnionMemberIsRejected(): void + { + $this->exec('Duplicate type `Foo` is redundant', 'type_rule_dup_class_union.php'); + } + + public function testBoolWithFalseIsRedundant(): void + { + $this->exec('Duplicate type `false` is redundant', 'type_rule_bool_false.php'); + } + + public function testTrueWithFalseMustUseBool(): void + { + $this->exec('Type contains both `true` and `false`, `bool` must be used instead', 'type_rule_true_false.php'); + } + + public function testMixedCannotBeUnionMember(): void + { + $this->exec('Type `mixed` can only be used as a standalone type', 'type_rule_mixed_union.php'); + } + + public function testMixedCannotBeNullable(): void + { + $this->exec('Type `mixed` cannot be marked as nullable since mixed already includes null', 'type_rule_nullable_mixed.php'); + } + + public function testVoidCannotBeUnionMember(): void + { + $this->exec('Type `void` can only be used as a standalone type', 'type_rule_void_union.php'); + } + + public function testIterableExpansionDetectsArrayDuplicate(): void + { + $this->exec('Duplicate type `array` is redundant', 'type_rule_iterable_array.php'); + } + + public function testScalarCannotJoinIntersection(): void + { + $this->exec('Type `int` cannot be part of an intersection type', 'type_rule_intersect_scalar.php'); + } + + public function testDuplicateIntersectionMemberIsRejected(): void + { + $this->exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.php'); + } + + public function testSelfCannotJoinBareIntersection(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_intersect_method.php'); + } + + public function testSelfCannotJoinDnfIntersection(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_method.php'); + } + + public function testParentCannotJoinDnfIntersection(): void + { + $this->exec('Type `parent` cannot be part of an intersection type', 'type_rule_parent_dnf_method.php'); + } + + public function testStaticCannotJoinBareIntersectionReturn(): void + { + $this->exec('Type `static` cannot be part of an intersection type', 'type_rule_static_intersect_return.php'); + } + + public function testStaticCannotJoinDnfIntersectionReturn(): void + { + $this->exec('Type `static` cannot be part of an intersection type', 'type_rule_static_dnf_return.php'); + } + + public function testSelfCannotJoinDnfIntersectionInProperty(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_property.php'); + } + + public function testParentCannotJoinDnfIntersectionInConstant(): void + { + $this->exec('Type `parent` cannot be part of an intersection type', 'type_rule_parent_dnf_const.php'); + } + + public function testSelfCannotJoinDnfIntersectionInPromotedParam(): void + { + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_self_dnf_promoted.php'); + } + + public function testClassScopeKeywordBesideDnfGroupStillCompiles(): void + { + $this->compile('type_rule_keyword_beside_dnf_valid.php'); + } + + public function testObjectWithClassTypeIsRedundant(): void + { + $this->exec( + 'Type `Foo|object` contains both object and a class type, which is redundant', + 'type_rule_object_class_union.php', + ); + } + + public function testObjectWithClassTypeIsRedundantInEitherOrder(): void + { + $this->exec( + 'Type `Foo|object` contains both object and a class type, which is redundant', + 'type_rule_object_class_union_reversed.php', + ); + } + + public function testObjectWithInterfaceTypeIsRedundant(): void + { + $this->exec( + 'Type `Ifc|object` contains both object and a class type, which is redundant', + 'type_rule_object_interface_union.php', + ); + } + + public function testObjectWithDnfGroupIsRedundant(): void + { + $this->exec( + 'Type `(A&B)|object` contains both object and a class type, which is redundant', + 'type_rule_object_dnf_union.php', + ); + } + + public function testPermutedDnfGroupIsRedundant(): void + { + $this->exec('Type `B&A` is redundant with type `A&B`', 'type_rule_dnf_permuted.php'); + } + + public function testDnfGroupMoreRestrictiveThanPlainMemberIsRedundant(): void + { + $this->exec( + 'Type `A&B` is redundant as it is more restrictive than type `A`', + 'type_rule_dnf_subset.php', + ); + } + + public function testDnfGroupMoreRestrictiveThanPlainMemberIsRedundantInEitherOrder(): void + { + $this->exec( + 'Type `A&B` is redundant as it is more restrictive than type `A`', + 'type_rule_dnf_subset_reversed.php', + ); + } + + public function testDnfSupersetGroupIsRedundant(): void + { + $this->exec( + 'Type `A&B&C2` is redundant as it is more restrictive than type `A&B`', + 'type_rule_dnf_superset_group.php', + ); + } + + public function testStaticReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_return_global.php'); + } + + public function testStaticUnionReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_union_return_global.php'); + } + + public function testSelfReturnRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_return_global.php'); + } + + public function testSelfParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_param_global.php'); + } + + public function testSelfUnionReturnRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_union_return_global.php'); + } + + public function testSelfNullableParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_nullable_param_global.php'); + } + + public function testSelfDnfParameterRequiresClassScope(): void + { + $this->exec('Cannot use "self" when no class scope is active', 'type_rule_self_dnf_param_global.php'); + } + + public function testParentParameterRequiresClassScope(): void + { + $this->exec('Cannot use "parent" when no class scope is active', 'type_rule_parent_param_global.php'); + } + + public function testParentParameterRequiresParentClass(): void + { + $this->exec('Cannot use "parent" when current class scope has no parent', 'type_rule_parent_no_parent_method.php'); + } + + public function testParentPropertyRequiresParentClass(): void + { + $this->exec('Cannot use "parent" when current class scope has no parent', 'type_rule_parent_no_parent_property.php'); + } + + public function testDuplicateImplementsIsRejected(): void + { + $this->exec('Class `C` cannot implement previously implemented interface `Ia`', 'type_rule_implements_dup.php'); + } + + public function testWellFormedCompoundTypesStillCompile(): void + { + $this->compile('type_rule_valid.php'); + } + + public function testClassScopeKeywordsInsideClassLikeScopesStillCompile(): void + { + $this->compile('type_rule_scope_valid.php'); + } +} diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index 87a9d897..8ca355af 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -159,7 +159,7 @@ protected function buildTypeCheckFromNode(NodeAbstract $typeNode, bool $includeS foreach ($typeNode->types as $subType) { $nameLower = strtolower($this->parseIdentifier($subType)); if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { - $this->fatalError($subType, "Type '{$nameLower}' cannot be part of an intersection type"); + $this->fatalError($subType, "Type `{$nameLower}` cannot be part of an intersection type"); } } $clause = $this->buildTypeCheckClause($typeNode); diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 408a56ad..524911f1 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -993,6 +993,16 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } + // Class-scope type keywords need an active class scope, in every + // declaration context and at any nesting depth. Methods always have + // one (class, interface, trait, enum); a free function never does, + // no matter where it is declared. + $classScope = $v instanceof Node\Stmt\ClassMethod; + $scopeHasParent = $classScope && $this->currentClassScopeHasParent(); + $this->validateClassScopeTypeKeywords($v->returnType, $classScope, $scopeHasParent); + foreach ($v->params as $param) { + $this->validateClassScopeTypeKeywords($param->type, $classScope, $scopeHasParent); + } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); $this->assertSupportedNativeObjectTypeNode($v->returnType, self::DECL_TYPE_OF_RETURN, $v); $nullableNativeReturn = $this->resolveNullableNativeObjectType( @@ -1273,6 +1283,19 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum } if (!$class instanceof Node\Stmt\Trait_) { $this->classDef->implements = $this->parseImplements($class->implements); + $implemented = []; + foreach ($this->classDef->implements as $i => $interfaceName) { + $interfaceLower = strtolower($interfaceName); + $errorNode = $class->implements[$i] ?? $class; + if (isset($implemented[$interfaceLower])) { + $kind = $class instanceof Node\Stmt\Enum_ ? 'Enum' : 'Class'; + $this->fatalError( + $errorNode, + "{$kind} `{$fullClassName}` cannot implement previously implemented interface `{$interfaceName}`", + ); + } + $implemented[$interfaceLower] = true; + } } else { $this->classDef->trait = $class; // Trait members are compiled later in the consuming class, but @@ -1562,6 +1585,7 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + $this->validateClassScopeTypeKeywords($v->type, true, $this->currentClassScopeHasParent()); [$declaredType, $class] = $v->type ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) : [null, '']; @@ -1705,6 +1729,7 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ } } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); + $this->validateClassScopeTypeKeywords($typeNode, true, $this->currentClassScopeHasParent()); [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( @@ -1776,6 +1801,298 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } + /** + * Validate compound well-formedness before resolving, so every context a + * type declaration is parsed in (parameters, returns, properties, class + * and interface constants, closures) shares the same Zend rules. + */ + protected function resolveTypeDecl(?NodeAbstract $type, int $what): array + { + $this->validateCompoundTypeDecl($type); + return parent::resolveTypeDecl($type, $what); + } + + /** + * Compile-time well-formedness of compound type declarations, mirroring + * Zend: standalone-only types inside unions, invalid nullable targets, + * duplicate members (after alias/namespace resolution, with iterable + * expanded to array|Traversable), the bool/true/false overlaps, + * non-class standard types and class-scope keywords (self, parent, + * static) inside intersections, whether bare or DNF, redundancy between + * whole DNF groups (a repeated member set in any order, or a group + * strictly more restrictive than another group or plain class member), + * and `object` absorbing every class type. + */ + private function validateCompoundTypeDecl(?NodeAbstract $type): void + { + if ($type instanceof NullableType) { + $inner = $type->type; + if (!$inner instanceof Node\Identifier && !$inner instanceof Node\Name) { + return; + } + $innerLower = strtolower($this->parseIdentifier($inner)); + if ($innerLower === 'mixed') { + $this->fatalError($type, 'Type `mixed` cannot be marked as nullable since mixed already includes null'); + } + if ($innerLower === 'null') { + $this->fatalError($type, '`null` cannot be marked as nullable'); + } + if ($innerLower === 'void' || $innerLower === 'never') { + $this->fatalError($type, "Type `{$innerLower}` can only be used as a standalone type"); + } + return; + } + if ($type instanceof UnionType) { + $this->validateUnionTypeDecl($type); + } elseif ($type instanceof IntersectionType) { + $this->validateIntersectionTypeDecl($type); + } + } + + private function validateUnionTypeDecl(UnionType $type): void + { + $seen = []; + $addMember = function (string $key, string $display, NodeAbstract $node) use (&$seen): void { + if (isset($seen[$key])) { + $this->fatalError($node, "Duplicate type `{$display}` is redundant"); + } + $seen[$key] = true; + }; + // Rendered like Zend's zend_type_to_string(): class types keep their + // source order in front, standard types follow in a fixed order. + $classish = []; + $builtins = []; + $hasObject = false; + $hasClassType = false; + // Every DNF group and plain class member, as a canonical member set, + // for Zend's whole-list redundancy comparison. + $groups = []; + foreach ($type->types as $member) { + if ($member instanceof IntersectionType) { + // A DNF group: its members obey the intersection rules. + $groupMembers = $this->validateIntersectionTypeDecl($member); + $display = implode('&', $groupMembers); + $classish[] = '(' . $display . ')'; + $hasClassType = true; + $groups[] = [array_keys($groupMembers), $display, $member]; + continue; + } + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'mixed' || $nameLower === 'void' || $nameLower === 'never') { + $this->fatalError($member, "Type `{$nameLower}` can only be used as a standalone type"); + } + if ($nameLower === 'bool' || $nameLower === 'false' || $nameLower === 'true') { + // Zend folds false/true into bool: a union may not repeat the + // overlap, and naming both literals asks for bool instead. + if (($nameLower === 'true' && isset($seen['false'])) + || ($nameLower === 'false' && isset($seen['true'])) + ) { + $this->fatalError($member, 'Type contains both `true` and `false`, `bool` must be used instead'); + } + if ($nameLower === 'bool') { + foreach (['false', 'true'] as $literal) { + if (isset($seen[$literal])) { + $this->fatalError($member, "Duplicate type `{$literal}` is redundant"); + } + } + } elseif (isset($seen['bool'])) { + $this->fatalError($member, "Duplicate type `{$nameLower}` is redundant"); + } + $addMember($nameLower, $nameLower, $member); + $builtins[] = $nameLower; + continue; + } + if ($nameLower === 'iterable') { + // Zend expands iterable to array|Traversable before the + // redundancy check and reports the overlapping component. + // The expansion alone does not count as a class type for the + // object-redundancy rule. + $addMember('iterable', 'iterable', $member); + $addMember('array', 'array', $member); + $addMember('traversable', 'Traversable', $member); + $classish[] = 'Traversable'; + $builtins[] = 'array'; + continue; + } + if (isset($this->zendTypeMap[$nameLower])) { + $addMember($nameLower, $nameLower, $member); + if ($nameLower === 'object') { + $hasObject = true; + } else { + $builtins[] = $nameLower; + } + continue; + } + if (in_array($nameLower, ['self', 'parent', 'static'], true)) { + $addMember($nameLower, $nameLower, $member); + $classish[] = $nameLower; + $hasClassType = true; + continue; + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $addMember(strtolower($resolved), $resolved, $member); + $classish[] = $resolved; + $hasClassType = true; + $groups[] = [[strtolower($resolved)], $resolved, $member]; + } + + // Whole-DNF redundancy: Zend compares every pair of intersection + // groups and plain class members as canonical member sets. An equal + // set in any member order is a repeat; a strict superset is redundant + // because it is more restrictive than the smaller type it can never + // widen: (A&B)|(B&A), (A&B)|A and A|(A&B) are all rejected. + $groupCount = count($groups); + for ($i = 0; $i < $groupCount; $i++) { + for ($j = $i + 1; $j < $groupCount; $j++) { + [$setI, $displayI] = $groups[$i]; + [$setJ, $displayJ, $nodeJ] = $groups[$j]; + if (count($setI) === count($setJ)) { + if (array_diff($setI, $setJ) === []) { + $this->fatalError($nodeJ, "Type `{$displayJ}` is redundant with type `{$displayI}`"); + } + } elseif (count($setI) > count($setJ)) { + if (array_diff($setJ, $setI) === []) { + $this->fatalError($groups[$i][2], "Type `{$displayI}` is redundant as it is more restrictive than type `{$displayJ}`"); + } + } elseif (array_diff($setI, $setJ) === []) { + $this->fatalError($nodeJ, "Type `{$displayJ}` is redundant as it is more restrictive than type `{$displayI}`"); + } + } + } + + if ($hasObject && $hasClassType) { + // `object` already accepts every object: naming a class type + // (including self/parent/static and DNF groups) beside it is + // redundant. Zend rejects the whole declared type. + $order = array_flip(['callable', 'object', 'array', 'string', 'int', 'float', 'bool', 'false', 'true', 'null']); + $builtins[] = 'object'; + usort($builtins, static fn (string $a, string $b): int => ($order[$a] ?? 99) <=> ($order[$b] ?? 99)); + $typeStr = implode('|', array_merge($classish, $builtins)); + $this->fatalError($type, "Type `{$typeStr}` contains both object and a class type, which is redundant"); + } + } + + /** + * @return array resolved member names in declaration + * order, keyed by their lowercase form + */ + private function validateIntersectionTypeDecl(IntersectionType $type): array + { + $seen = []; + foreach ($type->types as $member) { + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { + // Zend never resolves class-scope keywords inside an + // intersection, bare or as a DNF member of a union: the + // scope errors ("no class scope", "no parent") take + // precedence via validateClassScopeTypeKeywords, then any + // surviving keyword is rejected here. buildTypeCheckFromNode + // only catches the top-level intersection case, so DNF + // members must be rejected at this layer. + $this->fatalError($member, "Type `{$nameLower}` cannot be part of an intersection type"); + } + if (in_array($nameLower, [ + 'int', 'float', 'bool', 'false', 'true', 'string', 'array', + 'object', 'mixed', 'null', 'void', 'never', 'callable', 'iterable', + ], true)) { + $this->fatalError($member, "Type `{$nameLower}` cannot be part of an intersection type"); + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $resolvedLower = strtolower($resolved); + if (isset($seen[$resolvedLower])) { + $this->fatalError($member, "Duplicate type `{$resolved}` is redundant"); + } + $seen[$resolvedLower] = $resolved; + } + return $seen; + } + + /** + * Zend rejects class-scope type keywords at compile time in every + * declaration context and at any nesting depth (nullable, union, + * intersection, DNF): `self`, `parent`, and `static` need an active + * class scope, and `parent` additionally needs that scope to have a + * parent class. A free function never has a class scope, no matter + * where it is declared; traits keep `parent` late-bound until the + * consuming class is known. (`static` outside a return type never + * reaches the compiler: PHP's grammar rejects it in parameter and + * property types, and class-constant types — where Zend accepts it — + * always have a class scope.) + */ + private function validateClassScopeTypeKeywords(?NodeAbstract $type, bool $classScope, bool $hasParent): void + { + if ($type === null) { + return; + } + if ($type instanceof NullableType) { + $this->validateClassScopeTypeKeywords($type->type, $classScope, $hasParent); + return; + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $member) { + $this->validateClassScopeTypeKeywords($member, $classScope, $hasParent); + } + return; + } + if (!$type instanceof Node\Identifier && !$type instanceof Node\Name) { + return; + } + $nameLower = strtolower($this->parseIdentifier($type)); + if (!in_array($nameLower, ['self', 'parent', 'static'], true)) { + return; + } + if (!$classScope) { + $this->fatalError($type, "Cannot use \"{$nameLower}\" when no class scope is active"); + } + if ($nameLower === 'parent' && !$hasParent) { + $this->fatalError($type, 'Cannot use "parent" when current class scope has no parent'); + } + } + + /** + * Whether `parent` may appear in a type declared in the current + * class-like scope: the class has a parent, or the scope is a trait + * where `parent` stays late-bound until the consuming class is known. + * Interfaces and enums never have a parent class. + */ + private function currentClassScopeHasParent(): bool + { + if ($this->classDef === null) { + return false; + } + return $this->classDef->trait !== null || $this->classDef->extends !== ''; + } + + /** + * Whether a declared type mentions `callable` outside an intersection. + * Zend forbids callable in property and class-constant types; members of + * an intersection are rejected separately as non-class types. + */ + private function typeDeclContainsCallable(NodeAbstract $typeNode): bool + { + if ($typeNode instanceof NullableType) { + return $this->typeDeclContainsCallable($typeNode->type); + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + if ($this->typeDeclContainsCallable($member)) { + return true; + } + } + return false; + } + if ($typeNode instanceof IntersectionType) { + return false; + } + return strtolower($this->parseIdentifier($typeNode)) === 'callable'; + } + private function validateAsymmetricPropertyDeclaration( string $name, int $flags, @@ -2448,6 +2765,9 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void $this->fatalError($stmt, "Duplicate constant `{$constName}`"); } if ($stmt->type) { + // Interface constants have a class scope but never a + // parent class, matching Zend. + $this->validateClassScopeTypeKeywords($stmt->type, true, false); [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); } else { $class = ''; @@ -2575,6 +2895,8 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void } } + // Interface properties have a class scope but never a parent class. + $this->validateClassScopeTypeKeywords($property->type, true, false); [$type, $class] = $this->resolveTypeDecl($property->type, self::DECL_TYPE_OF_PROPERTY); $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) {