From 31fe5bea07ab31232b74b7f93d61a1894c3e5f0b Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 16:00:01 +0200 Subject: [PATCH 1/5] fix(preprocessor): validate compound type declarations and class-scope type keywords resolveTypeDecl now runs a shared well-formedness pass before resolving, so parameters, returns, properties, class/interface constants, and closure signatures all obey Zend's compile-time compound-type rules (each probed on 8.4.13): - duplicate union members, case-insensitive and after alias/namespace resolution ("Duplicate type int is redundant", "Duplicate type App\Sub\Thing is redundant"); iterable is expanded to array|Traversable first, so iterable|array and iterable|\Traversable report the overlapping component exactly like Zend, while a namespace-local Traversable stays legal - bool with false/true names the literal as the duplicate in either order; true|false demands bool ("Type contains both true and false, bool must be used instead") - mixed/void/never inside a union ("... can only be used as a standalone type"), ?mixed ("Type mixed cannot be marked as nullable since mixed already includes null"), ?null, ?void, ?never - intersection members must be class types ("Type int cannot be part of an intersection type"); duplicate intersection members are redundant; self/parent/static keep the established TypeCheckGenerator diagnostic; redundancy between whole DNF groups is not checked (Zend uses a distinct "Type X&Y is redundant with type X&Y" pass) - self/static return types on free functions ("Cannot use \"static\" when no class scope is active"); closures keep accepting them since they may be bound to a scope later, matching Zend - duplicate interfaces in an implements list, for classes and enums ("Class A cannot implement previously implemented interface I"); duplicate trait use stays legal - Zend deduplicates it silently --- src/Preprocessor.php | 161 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 389f5107..c0b0000d 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -998,6 +998,15 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } + // `self`/`static` return types need a class scope; Zend rejects them + // on free functions at compile time. `parent` is already rejected in + // parseTypeDecl for every declaration context. + if (($returnTypeKeyword === 'self' || $returnTypeKeyword === 'static') + && $v instanceof Node\Stmt\Function_ + && $this->classDef === null + ) { + $this->fatalError($v->returnType, "Cannot use \"{$returnTypeKeyword}\" when no class scope is active"); + } [$returnType, $class] = $this->resolveTypeDecl($v->returnType, self::DECL_TYPE_OF_RETURN); $this->assertSupportedNativeObjectTypeNode($v->returnType, self::DECL_TYPE_OF_RETURN, $v); $nullableNativeReturn = $this->resolveNullableNativeObjectType( @@ -1278,6 +1287,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 @@ -1809,6 +1831,145 @@ 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, and + * non-class standard types inside intersections. Redundancy between whole + * DNF groups is not checked. + */ + 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; + }; + foreach ($type->types as $member) { + if ($member instanceof IntersectionType) { + // A DNF group: its members obey the intersection rules. + $this->validateIntersectionTypeDecl($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); + continue; + } + if ($nameLower === 'iterable') { + // Zend expands iterable to array|Traversable before the + // redundancy check and reports the overlapping component. + $addMember('iterable', 'iterable', $member); + $addMember('array', 'array', $member); + $addMember('traversable', 'Traversable', $member); + continue; + } + if (isset($this->zendTypeMap[$nameLower]) + || in_array($nameLower, ['self', 'parent', 'static'], true) + ) { + $addMember($nameLower, $nameLower, $member); + continue; + } + $resolved = $member instanceof Node\Name\FullyQualified + ? $member->toString() + : $this->getNamespacedClassName($name); + $addMember(strtolower($resolved), $resolved, $member); + } + } + + private function validateIntersectionTypeDecl(IntersectionType $type): void + { + $seen = []; + foreach ($type->types as $member) { + $name = $this->parseIdentifier($member); + $nameLower = strtolower($name); + if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { + // Rejected later by buildTypeCheckFromNode with its + // established "cannot be part of an intersection type" text. + continue; + } + 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] = true; + } + } + + /** + * 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. + /** * Whether a declared type mentions `callable` outside an intersection. * Zend forbids callable in property and class-constant types; callable From 2cc459f94784e974a5d3d12bff7ba4f6681e6173 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:14:25 +0200 Subject: [PATCH 2/5] test(preprocessor): cover compound type declaration rules --- phpunit/code/type_rule_bool_false.php | 4 + phpunit/code/type_rule_dup_class_union.php | 5 ++ phpunit/code/type_rule_dup_union.php | 4 + phpunit/code/type_rule_implements_dup.php | 5 ++ phpunit/code/type_rule_intersect_dup.php | 5 ++ phpunit/code/type_rule_intersect_scalar.php | 4 + phpunit/code/type_rule_iterable_array.php | 4 + phpunit/code/type_rule_mixed_union.php | 4 + phpunit/code/type_rule_nullable_mixed.php | 4 + phpunit/code/type_rule_self_return_global.php | 4 + .../code/type_rule_static_return_global.php | 4 + phpunit/code/type_rule_true_false.php | 4 + phpunit/code/type_rule_valid.php | 6 ++ phpunit/code/type_rule_void_union.php | 4 + phpunit/src/CompoundTypeValidationTest.php | 80 +++++++++++++++++++ 15 files changed, 141 insertions(+) create mode 100644 phpunit/code/type_rule_bool_false.php create mode 100644 phpunit/code/type_rule_dup_class_union.php create mode 100644 phpunit/code/type_rule_dup_union.php create mode 100644 phpunit/code/type_rule_implements_dup.php create mode 100644 phpunit/code/type_rule_intersect_dup.php create mode 100644 phpunit/code/type_rule_intersect_scalar.php create mode 100644 phpunit/code/type_rule_iterable_array.php create mode 100644 phpunit/code/type_rule_mixed_union.php create mode 100644 phpunit/code/type_rule_nullable_mixed.php create mode 100644 phpunit/code/type_rule_self_return_global.php create mode 100644 phpunit/code/type_rule_static_return_global.php create mode 100644 phpunit/code/type_rule_true_false.php create mode 100644 phpunit/code/type_rule_valid.php create mode 100644 phpunit/code/type_rule_void_union.php create mode 100644 phpunit/src/CompoundTypeValidationTest.php 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('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 testStaticReturnRequiresClassScope(): void + { + $this->exec('Cannot use "static" when no class scope is active', 'type_rule_static_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 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'); + } +} From 0eca3f5f199768b53ed7c1f4bdf20a4dbef14e81 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:11:47 +0200 Subject: [PATCH 3/5] fix(preprocessor): complete Zend union redundancy and class-scope keyword rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Zend compile-time type rules were still accepted, all probed against PHP 8.4.13: - object absorbing class types: a union naming object beside any class type (a class, interface, or enum name, self/parent/static, or a DNF group) is rejected in either member order with Zend's message and type rendering — class types first in source order, then the standard types in Zend's canonical order, e.g. "Type Foo|object|null contains both object and a class type, which is redundant". iterable beside object stays legal, matching Zend. - whole-DNF redundancy: intersection groups and plain class members are compared as canonical, order-insensitive member sets. An equal set is "Type B&A is redundant with type A&B"; a strict superset is rejected as more restrictive, in both orders: (A&B)|A, A|(A&B), and (A&B)|(A&B&C2) all fail like Zend. The stale comment claiming whole-DNF redundancy is not checked is gone. - class-scope type keywords: self/parent/static are validated recursively through nullable, union, intersection, and DNF nodes in parameters, returns, properties, and class or interface constants. A free function has no class scope (Zend errors no matter where it is declared), while closures keep their runtime binding and stay exempt. parent additionally requires the scope to have a parent class ("Cannot use \"parent\" when current class scope has no parent"), with traits exempt because parent stays 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 Zend accepts it in class-constant types, which always have a class scope. --- phpunit/code/type_rule_dnf_permuted.php | 4 + phpunit/code/type_rule_dnf_subset.php | 4 + .../code/type_rule_dnf_subset_reversed.php | 4 + phpunit/code/type_rule_dnf_superset_group.php | 4 + phpunit/code/type_rule_object_class_union.php | 4 + .../type_rule_object_class_union_reversed.php | 4 + phpunit/code/type_rule_object_dnf_union.php | 4 + .../code/type_rule_object_interface_union.php | 4 + .../type_rule_parent_no_parent_method.php | 4 + .../type_rule_parent_no_parent_property.php | 4 + .../code/type_rule_parent_param_global.php | 4 + phpunit/code/type_rule_scope_valid.php | 47 +++++ .../code/type_rule_self_dnf_param_global.php | 4 + .../type_rule_self_nullable_param_global.php | 4 + phpunit/code/type_rule_self_param_global.php | 4 + .../type_rule_self_union_return_global.php | 4 + .../type_rule_static_union_return_global.php | 4 + phpunit/code/type_rule_valid.php | 2 + phpunit/src/CompoundTypeValidationTest.php | 109 ++++++++++- src/Preprocessor.php | 170 ++++++++++++++++-- 20 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 phpunit/code/type_rule_dnf_permuted.php create mode 100644 phpunit/code/type_rule_dnf_subset.php create mode 100644 phpunit/code/type_rule_dnf_subset_reversed.php create mode 100644 phpunit/code/type_rule_dnf_superset_group.php create mode 100644 phpunit/code/type_rule_object_class_union.php create mode 100644 phpunit/code/type_rule_object_class_union_reversed.php create mode 100644 phpunit/code/type_rule_object_dnf_union.php create mode 100644 phpunit/code/type_rule_object_interface_union.php create mode 100644 phpunit/code/type_rule_parent_no_parent_method.php create mode 100644 phpunit/code/type_rule_parent_no_parent_property.php create mode 100644 phpunit/code/type_rule_parent_param_global.php create mode 100644 phpunit/code/type_rule_scope_valid.php create mode 100644 phpunit/code/type_rule_self_dnf_param_global.php create mode 100644 phpunit/code/type_rule_self_nullable_param_global.php create mode 100644 phpunit/code/type_rule_self_param_global.php create mode 100644 phpunit/code/type_rule_self_union_return_global.php create mode 100644 phpunit/code/type_rule_static_union_return_global.php diff --git a/phpunit/code/type_rule_dnf_permuted.php b/phpunit/code/type_rule_dnf_permuted.php new file mode 100644 index 00000000..51e9de70 --- /dev/null +++ b/phpunit/code/type_rule_dnf_permuted.php @@ -0,0 +1,4 @@ +exec('Duplicate type `Ix` is redundant', 'type_rule_intersect_dup.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'); @@ -77,4 +179,9 @@ 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/Preprocessor.php b/src/Preprocessor.php index c0b0000d..4fa514be 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -998,14 +998,15 @@ protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $ $returnTypeKeyword = $rtLower; } } - // `self`/`static` return types need a class scope; Zend rejects them - // on free functions at compile time. `parent` is already rejected in - // parseTypeDecl for every declaration context. - if (($returnTypeKeyword === 'self' || $returnTypeKeyword === 'static') - && $v instanceof Node\Stmt\Function_ - && $this->classDef === null - ) { - $this->fatalError($v->returnType, "Cannot use \"{$returnTypeKeyword}\" when no class scope is active"); + // 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); @@ -1598,6 +1599,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, '']; @@ -1751,6 +1753,7 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ // Resolving the declaration also runs the common compound-type // validation (callable as an intersection/DNF member is rejected // there, ahead of the property-specific rule, matching Zend). + $this->validateClassScopeTypeKeywords($typeNode, true, $this->currentClassScopeHasParent()); [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); // `callable` is a runtime-context type (a string or array may or may // not be callable depending on scope), so Zend forbids it in property @@ -1846,9 +1849,11 @@ protected function resolveTypeDecl(?NodeAbstract $type, int $what): array * 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, and - * non-class standard types inside intersections. Redundancy between whole - * DNF groups is not checked. + * expanded to array|Traversable), the bool/true/false overlaps, + * non-class standard types inside intersections, 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 { @@ -1885,10 +1890,23 @@ private function validateUnionTypeDecl(UnionType $type): void } $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. - $this->validateIntersectionTypeDecl($member); + $groupMembers = $this->validateIntersectionTypeDecl($member); + $display = implode('&', $groupMembers); + $classish[] = '(' . $display . ')'; + $hasClassType = true; + $groups[] = [array_keys($groupMembers), $display, $member]; continue; } $name = $this->parseIdentifier($member); @@ -1914,30 +1932,86 @@ private function validateUnionTypeDecl(UnionType $type): void $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]) - || in_array($nameLower, ['self', 'parent', 'static'], true) - ) { + 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"); } } - private function validateIntersectionTypeDecl(IntersectionType $type): void + /** + * @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) { @@ -1961,8 +2035,65 @@ private function validateIntersectionTypeDecl(IntersectionType $type): void if (isset($seen[$resolvedLower])) { $this->fatalError($member, "Duplicate type `{$resolved}` is redundant"); } - $seen[$resolvedLower] = true; + $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 !== ''; } /** @@ -2722,6 +2853,9 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void ); } 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); if ($this->typeDeclContainsCallable($stmt->type)) { $this->fatalError( @@ -2858,6 +2992,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) { From d43cde7732be96f8a96d9cf646dc5035547a6447 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:31:10 +0200 Subject: [PATCH 4/5] fix(preprocessor): reject class-scope keywords in intersections at the compound layer validateIntersectionTypeDecl skipped self/parent/static on the assumption that buildTypeCheckFromNode rejects them later, but that rejection only runs when the top-level node is an IntersectionType: a DNF group nested inside a union goes through buildTypeCheckClause, which flattens the intersection and accepted the keyword as a late-bound class type. Inside a class, `(self&Countable)|stdClass $value` compiled while Zend fatals. Probed against PHP 8.4.13: a class-scope keyword can never be part of an intersection, bare or as a DNF member, in any declaration context. A bare `self&Ix` parameter, a `(self&Ix)|Other` parameter, promoted parameter, or property, a `(parent&Ix)|Other` parameter or class constant in a class with a parent, and `static&Ix` or `(static&Ix)|Other` return types all fail with "Type self cannot be part of an intersection type" in the matching spelling. The scope errors keep their Zend precedence: with no class scope, or no parent class, the "Cannot use ..." fatals from validateClassScopeTypeKeywords fire first, exactly as Zend orders them. A keyword as a plain union member beside a DNF group, e.g. `(Ia&Ib)|self`, stays legal. The compound layer now rejects the keyword directly, so bare and DNF shapes report the same text; the buildTypeCheckFromNode backstop and the ClassTest expectations adopt the same backtick rendering. --- .../type_rule_keyword_beside_dnf_valid.php | 11 +++++ phpunit/code/type_rule_parent_dnf_const.php | 4 ++ phpunit/code/type_rule_parent_dnf_method.php | 4 ++ phpunit/code/type_rule_self_dnf_method.php | 4 ++ phpunit/code/type_rule_self_dnf_promoted.php | 4 ++ phpunit/code/type_rule_self_dnf_property.php | 4 ++ .../code/type_rule_self_intersect_method.php | 4 ++ phpunit/code/type_rule_static_dnf_return.php | 4 ++ .../type_rule_static_intersect_return.php | 4 ++ phpunit/src/ClassTest.php | 6 +-- phpunit/src/CompoundTypeValidationTest.php | 45 +++++++++++++++++++ src/Generator/TypeCheckGenerator.php | 2 +- src/Preprocessor.php | 14 ++++-- 13 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 phpunit/code/type_rule_keyword_beside_dnf_valid.php create mode 100644 phpunit/code/type_rule_parent_dnf_const.php create mode 100644 phpunit/code/type_rule_parent_dnf_method.php create mode 100644 phpunit/code/type_rule_self_dnf_method.php create mode 100644 phpunit/code/type_rule_self_dnf_promoted.php create mode 100644 phpunit/code/type_rule_self_dnf_property.php create mode 100644 phpunit/code/type_rule_self_intersect_method.php create mode 100644 phpunit/code/type_rule_static_dnf_return.php create mode 100644 phpunit/code/type_rule_static_intersect_return.php diff --git a/phpunit/code/type_rule_keyword_beside_dnf_valid.php b/phpunit/code/type_rule_keyword_beside_dnf_valid.php new file mode 100644 index 00000000..0d9fb7a2 --- /dev/null +++ b/phpunit/code/type_rule_keyword_beside_dnf_valid.php @@ -0,0 +1,11 @@ +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 index c39be2d8..b8790fa3 100644 --- a/phpunit/src/CompoundTypeValidationTest.php +++ b/phpunit/src/CompoundTypeValidationTest.php @@ -59,6 +59,51 @@ 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( 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 4fa514be..fcc89efc 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1850,7 +1850,8 @@ protected function resolveTypeDecl(?NodeAbstract $type, int $what): array * 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 inside intersections, redundancy between + * 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. @@ -2018,9 +2019,14 @@ private function validateIntersectionTypeDecl(IntersectionType $type): array $name = $this->parseIdentifier($member); $nameLower = strtolower($name); if ($nameLower === 'self' || $nameLower === 'parent' || $nameLower === 'static') { - // Rejected later by buildTypeCheckFromNode with its - // established "cannot be part of an intersection type" text. - continue; + // 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', From 85183c5fd24f27789018eaf49ce6b3d657c7cd33 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Thu, 3 Sep 2026 10:28:29 +0200 Subject: [PATCH 5/5] fix(resolver): run compound type validation on the shared parseTypeDecl path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compound well-formedness rules lived in a Preprocessor::resolveTypeDecl override, tying them to the preprocessing pass, while closure and arrow-function signatures inside function bodies are only resolved during conversion. Follow the callable-intersection precedent from #64: validateCompoundTypeDecl and its union/intersection helpers move into NameResolutionTrait and run from parseTypeDecl(), the declaration funnel both compilation phases share, and the override is gone. Every context — parameters, returns, properties, class and interface constants, and now closure and arrow-function signatures generated during conversion — applies the same Zend rules. The class-scope keyword rules stay per-context in the preprocessor, deliberately off the shared path. Probed on PHP 8.4.13: a global closure or arrow function compiles while declaring self or static, because it may later be bound to a class scope, so closure signatures never run validateClassScopeTypeKeywords. Zend still rejects the keyword inside an intersection even there — `function (self&Countable $v) {}` at top level fatals with "Type self cannot be part of an intersection type" — so that rule stays unconditional on the shared path. New closure and arrow-function coverage, every fixture verified against Zend first: duplicate union members in a closure and an arrow function ("Duplicate type int is redundant"), a scalar intersection member ("Type int cannot be part of an intersection type"), callable in a DNF group ("Type callable cannot be part of an intersection type"), a permuted DNF group ("Type B&A is redundant with type A&B"), self inside a global closure's intersection, and positive compiles for global closures declaring self and static types. --- phpunit/code/type_rule_arrow_dup_union.php | 4 + .../code/type_rule_closure_callable_dnf.php | 4 + .../code/type_rule_closure_dnf_permuted.php | 8 + phpunit/code/type_rule_closure_dup_union.php | 4 + .../type_rule_closure_intersect_scalar.php | 4 + ...ype_rule_closure_self_intersect_global.php | 4 + .../type_rule_closure_self_return_valid.php | 6 + .../type_rule_closure_static_return_valid.php | 6 + phpunit/src/CompoundTypeValidationTest.php | 50 ++++ src/Preprocessor.php | 225 +----------------- src/Resolver/NameResolutionTrait.php | 215 +++++++++++++++++ 11 files changed, 313 insertions(+), 217 deletions(-) create mode 100644 phpunit/code/type_rule_arrow_dup_union.php create mode 100644 phpunit/code/type_rule_closure_callable_dnf.php create mode 100644 phpunit/code/type_rule_closure_dnf_permuted.php create mode 100644 phpunit/code/type_rule_closure_dup_union.php create mode 100644 phpunit/code/type_rule_closure_intersect_scalar.php create mode 100644 phpunit/code/type_rule_closure_self_intersect_global.php create mode 100644 phpunit/code/type_rule_closure_self_return_valid.php create mode 100644 phpunit/code/type_rule_closure_static_return_valid.php diff --git a/phpunit/code/type_rule_arrow_dup_union.php b/phpunit/code/type_rule_arrow_dup_union.php new file mode 100644 index 00000000..1ca1a31f --- /dev/null +++ b/phpunit/code/type_rule_arrow_dup_union.php @@ -0,0 +1,4 @@ + $value; +} diff --git a/phpunit/code/type_rule_closure_callable_dnf.php b/phpunit/code/type_rule_closure_callable_dnf.php new file mode 100644 index 00000000..c07eeb29 --- /dev/null +++ b/phpunit/code/type_rule_closure_callable_dnf.php @@ -0,0 +1,4 @@ +compile('type_rule_scope_valid.php'); } + + /* + * Closure and arrow-function signatures inside function bodies are only + * resolved during conversion, so they exercise the compound rules on the + * shared parseTypeDecl() path rather than through the preprocessor. + */ + + public function testClosureDuplicateUnionMemberIsRejected(): void + { + $this->exec('Duplicate type `int` is redundant', 'type_rule_closure_dup_union.php'); + } + + public function testArrowFunctionDuplicateUnionMemberIsRejected(): void + { + $this->exec('Duplicate type `int` is redundant', 'type_rule_arrow_dup_union.php'); + } + + public function testClosureScalarCannotJoinIntersection(): void + { + $this->exec('Type `int` cannot be part of an intersection type', 'type_rule_closure_intersect_scalar.php'); + } + + public function testClosureCallableCannotJoinDnfIntersection(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'type_rule_closure_callable_dnf.php'); + } + + public function testClosurePermutedDnfGroupIsRedundant(): void + { + $this->exec('Type `B&A` is redundant with type `A&B`', 'type_rule_closure_dnf_permuted.php'); + } + + public function testGlobalClosureSelfCannotJoinIntersection(): void + { + // Zend rejects class-scope keywords inside an intersection even in a + // global closure that could later be bound to a class scope. + $this->exec('Type `self` cannot be part of an intersection type', 'type_rule_closure_self_intersect_global.php'); + } + + public function testGlobalClosureMayDeclareSelfType(): void + { + // Zend compiles a global closure declaring self: the closure may be + // bound to a class scope before it is ever called. + $this->compile('type_rule_closure_self_return_valid.php'); + } + + public function testGlobalClosureMayDeclareStaticReturn(): void + { + $this->compile('type_rule_closure_static_return_valid.php'); + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index fcc89efc..b706bb49 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1834,218 +1834,6 @@ 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, @@ -2057,6 +1845,14 @@ private function validateIntersectionTypeDecl(IntersectionType $type): array * 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.) + * + * Deliberately applied per declaration context rather than on the shared + * parseTypeDecl() path that carries the compound well-formedness rules: + * Zend compiles a global closure or arrow function declaring self/static + * because it may later be bound to a class scope, so closure signatures + * must never run through this check. (Keywords inside an intersection are + * still rejected even there — validateIntersectionTypeDecl handles that + * on the shared path.) */ private function validateClassScopeTypeKeywords(?NodeAbstract $type, bool $classScope, bool $hasParent): void { @@ -2102,11 +1898,6 @@ private function currentClassScopeHasParent(): bool 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. - /** * Whether a declared type mentions `callable` outside an intersection. * Zend forbids callable in property and class-constant types; callable diff --git a/src/Resolver/NameResolutionTrait.php b/src/Resolver/NameResolutionTrait.php index ef2eba5e..9616a435 100644 --- a/src/Resolver/NameResolutionTrait.php +++ b/src/Resolver/NameResolutionTrait.php @@ -169,6 +169,7 @@ protected function parseTypeDecl(?NodeAbstract $type, int $what, string &$class) return Type::VAR; } $this->assertTypeDeclIntersectionsHaveNoCallable($type); + $this->validateCompoundTypeDecl($type); if ($type instanceof UnionType || $type instanceof NullableType || $type instanceof IntersectionType) { // Complex types are uniformly treated as mixed/var at the static stage; the runtime typeCheck provides the fallback. return Type::VAR; @@ -235,4 +236,218 @@ private function assertTypeDeclIntersectionsHaveNoCallable(NodeAbstract $typeNod } } } + + /** + * 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. + * + * Lives on the common declaration path in parseTypeDecl() so both + * compilation phases share the same rules: preprocessing covers named + * functions, methods, properties, and constants, while closure and + * arrow-function signatures inside function bodies are only resolved + * during conversion. The class-scope keyword rules ("no class scope", + * "no parent") deliberately stay out of this path, applied per context + * by the preprocessor: Zend compiles a global closure declaring + * self/static because it may later be bound to a class scope, yet even + * there still rejects those keywords inside an intersection (probed on + * 8.4.13), which is why the intersection rule below is unconditional. + */ + 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 — not + // even in a global closure that could later be bound to a + // class scope: the scope errors ("no class scope", "no + // parent") take precedence via the preprocessor's + // 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; + } }