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('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'); + } + + 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 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 408a56ad..32adaed5 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 @@ -1565,6 +1570,13 @@ protected function parseClassConstDef(Node\Stmt\ClassConst $v): void [$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( + $v, + "Class constant `{$this->classDef->getNamespacedName(false)}::{$constName}` cannot have type `{$this->typeCheckNodeToString($v->type)}`", + ); + } foreach ($v->consts as $const) { $type = $declaredType; @@ -1705,7 +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->typeDeclContainsCallable($typeNode)) { + $this->fatalError( + $errorNode, + "Property `{$this->classDef->getNamespacedName(false)}::\${$name}` cannot have type `{$this->typeCheckNodeToString($typeNode)}`", + ); + } $this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode); $nullableNative = $this->resolveNullableNativeObjectType( $typeNode, @@ -1776,6 +1800,31 @@ 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; callable + * inside an intersection is rejected first, with its own diagnostic, by + * the common declaration validation in parseTypeDecl(). + */ + 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,11 +2493,14 @@ protected function parseInterface(Node\Stmt\Interface_ $v): void "Access type for interface constant `{$interfaceName}::{$constName}` must be public", ); } - 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()) { @@ -2457,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; } @@ -2579,6 +2634,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, 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'); + } + } + } + } }