From c18774bb6a8ee94b0b9a897082eab1787a67c5aa Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Mon, 31 Aug 2026 15:56:07 +0200 Subject: [PATCH 1/4] fix(preprocessor): reject variadic promoted properties and callable property/constant types Two promotion/type gaps against Zend (probed on 8.4.13): - `__construct(public int ...$x)` was accepted and even registered the property before the variadic-position check ran. A variadic parameter collects its arguments into an array, so there is no single value to promote; Zend fatals with "Cannot declare variadic promoted property". The check now precedes the property registration. - `callable` is a calling-scope-dependent type, so Zend forbids it in property types (declared, promoted, interface hooked) and class constant types (class and interface), bare or as a nullable/union member: "Property A::$x cannot have type ?callable" / "Class constant A::X cannot have type callable". Intersection members are left to the compound-type validation, which rejects every non-class standard type there. `void`/`never` property and parameter types were already rejected by parseTypeDecl ("The type `void`/`never` is allowed only for return type") - verified, no change needed; union members are covered by the compound-type validation. --- src/Preprocessor.php | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 408a56ad..585d782d 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -855,6 +855,11 @@ protected function parseParams(array $params, FunctionDef $functionDef): void if (!$this->classDef or !$this->methodDef or $this->methodDef->name !== '__construct') { $this->fatalError($param, 'Promoted properties are not supported'); } + // A variadic parameter collects arguments into an array, so no + // single value exists to promote into the property. + if ($param->variadic) { + $this->fatalError($param, 'Cannot declare variadic promoted property'); + } $nullable = $param->type instanceof NullableType; // Promoted property defaults belong to the constructor parameter, // not to the property default table. The property itself must stay @@ -1562,6 +1567,13 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) { + $constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : ''; + $this->fatalError( + $v, + "Class constant `{$this->classDef->getNamespacedName(false)}::{$constName}` cannot have type `{$this->typeCheckNodeToString($v->type)}`", + ); + } [$declaredType, $class] = $v->type ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) : [null, '']; @@ -1705,6 +1717,15 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ } } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); + // `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 + // types entirely - bare, nullable, or as a union member. + if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) { + $this->fatalError( + $errorNode, + "Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`", + ); + } [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( @@ -1776,6 +1797,30 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } + /** + * 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, @@ -2444,6 +2489,12 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void "Access type for interface constant `{$interfaceName}::{$constName}` must be public", ); } + if ($stmt->type !== null && $this->typeDeclContainsCallable($stmt->type)) { + $this->fatalError( + $stmt, + "Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`", + ); + } if ($this->interfaceDef->hasConstant($constName)) { $this->fatalError($stmt, "Duplicate constant `{$constName}`"); } @@ -2579,6 +2630,12 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { $name = $this->parseIdentifier($prop->name); + if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) { + $this->fatalError( + $property, + "Property `{$this->interfaceDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($property->type)}`", + ); + } if ($property->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false)) { $this->fatalCompileTimeAttribute( $property, From a985ff62a755baef9a16d6ff4aa73061e7f40c73 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Tue, 1 Sep 2026 12:13:25 +0200 Subject: [PATCH 2/4] test(preprocessor): cover promotion and property/constant type rules --- phpunit/code/const_rule_callable.php | 4 +++ phpunit/code/promotion_rule_variadic.php | 4 +++ phpunit/code/property_rule_callable.php | 4 +++ .../code/property_rule_callable_promoted.php | 4 +++ phpunit/code/property_rule_callable_union.php | 4 +++ phpunit/src/PromotionAndPropertyTypeTest.php | 34 +++++++++++++++++++ 6 files changed, 54 insertions(+) create mode 100644 phpunit/code/const_rule_callable.php create mode 100644 phpunit/code/promotion_rule_variadic.php create mode 100644 phpunit/code/property_rule_callable.php create mode 100644 phpunit/code/property_rule_callable_promoted.php create mode 100644 phpunit/code/property_rule_callable_union.php create mode 100644 phpunit/src/PromotionAndPropertyTypeTest.php diff --git a/phpunit/code/const_rule_callable.php b/phpunit/code/const_rule_callable.php new file mode 100644 index 00000000..f598eb15 --- /dev/null +++ b/phpunit/code/const_rule_callable.php @@ -0,0 +1,4 @@ +exec('Cannot declare variadic promoted property', 'promotion_rule_variadic.php'); + } + + public function testCallablePropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable.php'); + } + + public function testCallablePromotedPropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `callable`', 'property_rule_callable_promoted.php'); + } + + public function testCallableUnionPropertyTypeIsRejected(): void + { + $this->exec('Property `Bag::$fn` cannot have type `int|callable`', 'property_rule_callable_union.php'); + } + + public function testCallableClassConstantTypeIsRejected(): void + { + $this->exec('Class constant `Bag::FN` cannot have type `callable`', 'const_rule_callable.php'); + } +} From 75b719179c220d96ba1b33cfc0ace3494a5a5f90 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 10:09:48 +0200 Subject: [PATCH 3/4] fix(preprocessor): reject callable inside intersection and DNF types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typeDeclContainsCallable() deliberately skipped IntersectionType, so a DNF-nested callable such as `public (Traversable&callable)|stdClass $value;` sailed past the property checks and died in gen_stub on assert(!$type->isBuiltin); a bare `Traversable&callable` property compiled outright. Zend rejects callable while compiling the intersection type itself, with its own diagnostic ("Type callable cannot be part of an intersection type", probed on 8.4.13), in every declaration context and ahead of the property/constant-specific bans — `callable|(Traversable& callable)` reports the intersection conflict, not the property one. A dedicated assertTypeDeclIntersectionsHaveNoCallable() walk (nullable, union, intersection members) now runs before the existing typeDeclContainsCallable() checks in all contexts this branch guards: class properties, promoted properties (both via addClassProperty), typed class constants, and interface properties/constants. Tests cover the bare intersection member, DNF in first and second union member, the promoted and constant/interface variants, and a callable-free DNF property that must keep compiling. --- phpunit/code/const_rule_callable_dnf.php | 4 ++ phpunit/code/interface_rule_callable_dnf.php | 4 ++ phpunit/code/property_rule_callable_dnf.php | 4 ++ .../property_rule_callable_dnf_promoted.php | 4 ++ ...operty_rule_callable_dnf_second_member.php | 4 ++ .../property_rule_callable_intersection.php | 4 ++ phpunit/code/property_rule_dnf_valid.php | 4 ++ phpunit/src/PromotionAndPropertyTypeTest.php | 38 +++++++++++++++ src/Preprocessor.php | 48 ++++++++++++++++++- 9 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 phpunit/code/const_rule_callable_dnf.php create mode 100644 phpunit/code/interface_rule_callable_dnf.php create mode 100644 phpunit/code/property_rule_callable_dnf.php create mode 100644 phpunit/code/property_rule_callable_dnf_promoted.php create mode 100644 phpunit/code/property_rule_callable_dnf_second_member.php create mode 100644 phpunit/code/property_rule_callable_intersection.php create mode 100644 phpunit/code/property_rule_dnf_valid.php diff --git a/phpunit/code/const_rule_callable_dnf.php b/phpunit/code/const_rule_callable_dnf.php new file mode 100644 index 00000000..163a0407 --- /dev/null +++ b/phpunit/code/const_rule_callable_dnf.php @@ -0,0 +1,4 @@ +exec('Class constant `Bag::FN` cannot have type `callable`', 'const_rule_callable.php'); } + + public function testCallableInBareIntersectionIsRejected(): void + { + // Zend rejects callable while compiling the intersection type itself, + // with a dedicated diagnostic; without this check the type reaches + // gen_stub, which asserts intersection members are never builtin. + $this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_intersection.php'); + } + + public function testCallableInDnfPropertyTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf.php'); + } + + public function testCallableInSecondDnfMemberIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf_second_member.php'); + } + + public function testCallableInDnfPromotedPropertyTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'property_rule_callable_dnf_promoted.php'); + } + + public function testCallableInDnfClassConstantTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'const_rule_callable_dnf.php'); + } + + public function testCallableInDnfInterfaceMemberTypesIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'interface_rule_callable_dnf.php'); + } + + public function testCallableFreeDnfPropertyTypeStillCompiles(): void + { + $this->compile('property_rule_dnf_valid.php'); + } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 585d782d..d36edb55 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1567,6 +1567,9 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + if ($v->type !== null) { + $this->assertTypeDeclIntersectionsHaveNoCallable($v->type, $v); + } if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) { $constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : ''; $this->fatalError( @@ -1720,6 +1723,9 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ // `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 // types entirely - bare, nullable, or as a union member. + if ($typeNode !== null) { + $this->assertTypeDeclIntersectionsHaveNoCallable($typeNode, $errorNode); + } if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) { $this->fatalError( $errorNode, @@ -1797,10 +1803,42 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } + /** + * Zend rejects `callable` as an intersection member while compiling the + * type itself ("Type callable cannot be part of an intersection type"), + * in every declaration context and before any property/constant-specific + * rule fires (probed: `callable|(Traversable&callable)` reports the + * intersection conflict, not the property one). This covers bare + * intersections and DNF members like `(Traversable&callable)|stdClass`; + * without it the type reaches gen_stub, which asserts that intersection + * members are never builtin. + */ + private function assertTypeDeclIntersectionsHaveNoCallable(NodeAbstract $typeNode, NodeAbstract $errorNode): void + { + if ($typeNode instanceof NullableType) { + $this->assertTypeDeclIntersectionsHaveNoCallable($typeNode->type, $errorNode); + return; + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + $this->assertTypeDeclIntersectionsHaveNoCallable($member, $errorNode); + } + return; + } + if ($typeNode instanceof IntersectionType) { + foreach ($typeNode->types as $member) { + if (strtolower($this->parseIdentifier($member)) === 'callable') { + $this->fatalError($errorNode, 'Type callable cannot be part of an intersection type'); + } + } + } + } + /** * 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. + * Zend forbids callable in property and class-constant types; callable + * inside an intersection is rejected first, with its own diagnostic, by + * assertTypeDeclIntersectionsHaveNoCallable(). */ private function typeDeclContainsCallable(NodeAbstract $typeNode): bool { @@ -2489,6 +2527,9 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void "Access type for interface constant `{$interfaceName}::{$constName}` must be public", ); } + if ($stmt->type !== null) { + $this->assertTypeDeclIntersectionsHaveNoCallable($stmt->type, $stmt); + } if ($stmt->type !== null && $this->typeDeclContainsCallable($stmt->type)) { $this->fatalError( $stmt, @@ -2630,6 +2671,9 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { $name = $this->parseIdentifier($prop->name); + if ($property->type !== null) { + $this->assertTypeDeclIntersectionsHaveNoCallable($property->type, $property); + } if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) { $this->fatalError( $property, From 64c7cc4ad64af32adf40ad8b855b70733a38f325 Mon Sep 17 00:00:00 2001 From: Alessio Giacobbe Date: Wed, 2 Sep 2026 14:39:32 +0200 Subject: [PATCH 4/4] fix(preprocessor): validate callable intersections on the common type-declaration path assertTypeDeclIntersectionsHaveNoCallable() was invoked only from the property and class/interface-constant paths, so the same invalid type in a function parameter or return declaration bypassed the check and reached the later generator path: function consume(Traversable&callable $value): void {} function produce(): Traversable&callable {} Zend rejects both while compiling the type itself ("Type callable cannot be part of an intersection type", probed on 8.4.13), in every declaration context. The walk now lives in parseTypeDecl(), the declaration funnel behind resolveTypeDecl() that parameters, returns, properties, promoted properties, class and interface constants, and interface hooked properties already flow through; the per-context calls are gone, and the diagnostic points at the offending intersection member. Closure and arrow-function signatures resolved no full type node anywhere, so doGenClosure() now routes them through the same funnel - except bare class names, which the native-object walk there already resolves (and, inside trait methods, rewrites) via parseTypeDecl(). The property and constant paths resolve the declaration before applying their own bare/nullable/union callable bans, so a type like `callable|(Traversable&callable)` keeps reporting the intersection conflict first, as Zend does. New negative tests: parameter and return intersections, callable in a DNF parameter member, and closure parameter and return intersections (each probed against Zend 8.4.13); positive tests keep bare `callable` parameters and callable-free DNF properties compiling. --- ...osure_rule_callable_intersection_param.php | 5 ++ ...sure_rule_callable_intersection_return.php | 5 ++ phpunit/code/param_rule_callable_dnf.php | 4 + .../code/param_rule_callable_intersection.php | 4 + phpunit/code/param_rule_callable_valid.php | 8 ++ .../return_rule_callable_intersection.php | 4 + phpunit/src/PromotionAndPropertyTypeTest.php | 37 +++++++++- src/Generator/ClosureGenerator.php | 14 ++++ src/Preprocessor.php | 74 +++++-------------- src/Resolver/NameResolutionTrait.php | 34 +++++++++ 10 files changed, 130 insertions(+), 59 deletions(-) create mode 100644 phpunit/code/closure_rule_callable_intersection_param.php create mode 100644 phpunit/code/closure_rule_callable_intersection_return.php create mode 100644 phpunit/code/param_rule_callable_dnf.php create mode 100644 phpunit/code/param_rule_callable_intersection.php create mode 100644 phpunit/code/param_rule_callable_valid.php create mode 100644 phpunit/code/return_rule_callable_intersection.php diff --git a/phpunit/code/closure_rule_callable_intersection_param.php b/phpunit/code/closure_rule_callable_intersection_param.php new file mode 100644 index 00000000..6fec70e4 --- /dev/null +++ b/phpunit/code/closure_rule_callable_intersection_param.php @@ -0,0 +1,5 @@ +exec('Type callable cannot be part of an intersection type', 'interface_rule_callable_dnf.php'); } + public function testCallableInIntersectionParameterTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'param_rule_callable_intersection.php'); + } + + public function testCallableInDnfParameterTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'param_rule_callable_dnf.php'); + } + + public function testCallableInIntersectionReturnTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'return_rule_callable_intersection.php'); + } + + public function testCallableInIntersectionClosureParameterTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'closure_rule_callable_intersection_param.php'); + } + + public function testCallableInIntersectionClosureReturnTypeIsRejected(): void + { + $this->exec('Type callable cannot be part of an intersection type', 'closure_rule_callable_intersection_return.php'); + } + public function testCallableFreeDnfPropertyTypeStillCompiles(): void { $this->compile('property_rule_dnf_valid.php'); } + + public function testBareCallableParameterTypeStillCompiles(): void + { + $this->compile('param_rule_callable_valid.php'); + } } diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index 04d83c41..daf6e4a0 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -116,6 +116,20 @@ protected function genClosure(Expr\ArrowFunction|Expr\Closure $expr, array $para private function doGenClosure(Expr\ArrowFunction|Expr\Closure $expr, array $params, array $uses = []): string { + // Closure signatures flow through the same declaration validation in + // parseTypeDecl() as named functions (e.g. callable inside an + // intersection or DNF member). Bare class names are skipped here: the + // native-object walk below already resolves each of them through + // parseTypeDecl() and owns the trait-context name rewrite, so + // resolving them twice would re-qualify an already qualified name. + foreach ($params as $param) { + if (!$param->type instanceof Node\Name) { + $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + } + } + if (!$expr->returnType instanceof Node\Name) { + $this->resolveTypeDecl($expr->returnType, self::DECL_TYPE_OF_RETURN); + } if ($this->classDef?->nativeObject && !$expr->static) { $this->fatalError($expr, 'Native objects cannot be bound as $this to Zend closures'); } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index d36edb55..32adaed5 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1567,9 +1567,9 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); - if ($v->type !== null) { - $this->assertTypeDeclIntersectionsHaveNoCallable($v->type, $v); - } + [$declaredType, $class] = $v->type + ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) + : [null, '']; if ($v->type !== null && $this->typeDeclContainsCallable($v->type)) { $constName = $v->consts !== [] ? $this->parseIdentifier($v->consts[0]->name) : ''; $this->fatalError( @@ -1577,9 +1577,6 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void "Class constant `{$this->classDef->getNamespacedName(false)}::{$constName}` cannot have type `{$this->typeCheckNodeToString($v->type)}`", ); } - [$declaredType, $class] = $v->type - ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) - : [null, '']; foreach ($v->consts as $const) { $type = $declaredType; @@ -1720,19 +1717,19 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ } } $this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode); + // 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). + [$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 // types entirely - bare, nullable, or as a union member. - if ($typeNode !== null) { - $this->assertTypeDeclIntersectionsHaveNoCallable($typeNode, $errorNode); - } if ($typeNode !== null && $this->typeDeclContainsCallable($typeNode)) { $this->fatalError( $errorNode, "Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`", ); } - [$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY); $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( $typeNode, @@ -1803,42 +1800,11 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ return $propDef; } - /** - * Zend rejects `callable` as an intersection member while compiling the - * type itself ("Type callable cannot be part of an intersection type"), - * in every declaration context and before any property/constant-specific - * rule fires (probed: `callable|(Traversable&callable)` reports the - * intersection conflict, not the property one). This covers bare - * intersections and DNF members like `(Traversable&callable)|stdClass`; - * without it the type reaches gen_stub, which asserts that intersection - * members are never builtin. - */ - private function assertTypeDeclIntersectionsHaveNoCallable(NodeAbstract $typeNode, NodeAbstract $errorNode): void - { - if ($typeNode instanceof NullableType) { - $this->assertTypeDeclIntersectionsHaveNoCallable($typeNode->type, $errorNode); - return; - } - if ($typeNode instanceof UnionType) { - foreach ($typeNode->types as $member) { - $this->assertTypeDeclIntersectionsHaveNoCallable($member, $errorNode); - } - return; - } - if ($typeNode instanceof IntersectionType) { - foreach ($typeNode->types as $member) { - if (strtolower($this->parseIdentifier($member)) === 'callable') { - $this->fatalError($errorNode, 'Type callable cannot be part of an intersection type'); - } - } - } - } - /** * Whether a declared type mentions `callable` outside an intersection. * Zend forbids callable in property and class-constant types; callable * inside an intersection is rejected first, with its own diagnostic, by - * assertTypeDeclIntersectionsHaveNoCallable(). + * the common declaration validation in parseTypeDecl(). */ private function typeDeclContainsCallable(NodeAbstract $typeNode): bool { @@ -2527,20 +2493,14 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void "Access type for interface constant `{$interfaceName}::{$constName}` must be public", ); } - if ($stmt->type !== null) { - $this->assertTypeDeclIntersectionsHaveNoCallable($stmt->type, $stmt); - } - if ($stmt->type !== null && $this->typeDeclContainsCallable($stmt->type)) { - $this->fatalError( - $stmt, - "Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`", - ); - } - if ($this->interfaceDef->hasConstant($constName)) { - $this->fatalError($stmt, "Duplicate constant `{$constName}`"); - } if ($stmt->type) { [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); + if ($this->typeDeclContainsCallable($stmt->type)) { + $this->fatalError( + $stmt, + "Class constant `{$interfaceName}::{$constName}` cannot have type `{$this->typeCheckNodeToString($stmt->type)}`", + ); + } } else { $class = ''; $type = match ($const->value->getType()) { @@ -2549,6 +2509,9 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void default => Type::VAR, }; } + if ($this->interfaceDef->hasConstant($constName)) { + $this->fatalError($stmt, "Duplicate constant `{$constName}`"); + } $constInfo = $this->parseClassLikeConstant($const, $this->parseModifiers($stmt->flags), $type, $class, $stmt->type ? $type : null); $this->interfaceDef->constants[$constName] = $constInfo; } @@ -2671,9 +2634,6 @@ private function prepareInterfaceProperty(Node\Stmt\Property $property): void $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { $name = $this->parseIdentifier($prop->name); - if ($property->type !== null) { - $this->assertTypeDeclIntersectionsHaveNoCallable($property->type, $property); - } if ($property->type !== null && $this->typeDeclContainsCallable($property->type)) { $this->fatalError( $property, diff --git a/src/Resolver/NameResolutionTrait.php b/src/Resolver/NameResolutionTrait.php index 57cafe05..ef2eba5e 100644 --- a/src/Resolver/NameResolutionTrait.php +++ b/src/Resolver/NameResolutionTrait.php @@ -168,6 +168,7 @@ protected function parseTypeDecl(?NodeAbstract $type, int $what, string &$class) if ($type === null) { return Type::VAR; } + $this->assertTypeDeclIntersectionsHaveNoCallable($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; @@ -201,4 +202,37 @@ protected function parseTypeDecl(?NodeAbstract $type, int $what, string &$class) } } } + + /** + * Zend rejects `callable` as an intersection member while compiling the + * type itself ("Type callable cannot be part of an intersection type"), + * in every declaration context - parameters, returns, properties, + * promoted properties, class and interface constants, closures - and + * before any property/constant-specific rule fires (probed on 8.4.13: + * `callable|(Traversable&callable)` reports the intersection conflict, + * not the property one). Running the walk here, on the common + * declaration path, covers bare intersections and DNF members like + * `(Traversable&callable)|stdClass`; without it the type reaches + * gen_stub, which asserts that intersection members are never builtin. + */ + private function assertTypeDeclIntersectionsHaveNoCallable(NodeAbstract $typeNode): void + { + if ($typeNode instanceof NullableType) { + $this->assertTypeDeclIntersectionsHaveNoCallable($typeNode->type); + return; + } + if ($typeNode instanceof UnionType) { + foreach ($typeNode->types as $member) { + $this->assertTypeDeclIntersectionsHaveNoCallable($member); + } + return; + } + if ($typeNode instanceof IntersectionType) { + foreach ($typeNode->types as $member) { + if (strtolower($this->parseIdentifier($member)) === 'callable') { + $this->fatalError($member, 'Type callable cannot be part of an intersection type'); + } + } + } + } }