Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions phpunit/code/closure_rule_callable_intersection_param.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
function main() {
$f = function (Traversable&callable $x): void {};
$f(null);
}
5 changes: 5 additions & 0 deletions phpunit/code/closure_rule_callable_intersection_return.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
function main() {
$f = function (): Traversable&callable {};
$f();
}
4 changes: 4 additions & 0 deletions phpunit/code/const_rule_callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { const callable FN = 1; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/const_rule_callable_dnf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { const (Traversable&callable)|int FN = 1; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/interface_rule_callable_dnf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
interface Baggy { public (Traversable&callable)|stdClass $fn { get; } const (Traversable&callable)|int FN = 1; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/param_rule_callable_dnf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function consume((Traversable&callable)|stdClass $value): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/param_rule_callable_intersection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function consume(Traversable&callable $value): void {}

function main() {}
8 changes: 8 additions & 0 deletions phpunit/code/param_rule_callable_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php
function apply(callable $fn): void {
$fn();
}

function main() {
apply(function (): void {});
}
4 changes: 4 additions & 0 deletions phpunit/code/promotion_rule_variadic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public int ...$items) {} }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public callable $fn; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_dnf.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public (Traversable&callable)|stdClass $fn; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_dnf_promoted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public (Traversable&callable)|stdClass $fn) {} }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_dnf_second_member.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public (Countable&Traversable)|(Iterator&callable) $fn; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_intersection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public Traversable&callable $fn; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_promoted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public function __construct(public callable $fn) {} }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_callable_union.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public int|callable $fn; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/property_rule_dnf_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Bag { public (Countable&Traversable)|stdClass $it; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/return_rule_callable_intersection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function produce(): Traversable&callable {}

function main() {}
105 changes: 105 additions & 0 deletions phpunit/src/PromotionAndPropertyTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

/**
* Constructor promotion and property/constant type restrictions:
* no variadic promoted properties, `callable` is banned from
* property and class-constant types (bare, nullable, or union member),
* and `callable` inside an intersection or DNF member is rejected in
* every declaration context - parameters, returns, properties, promoted
* properties, constants, interface members, and closures.
*/
class PromotionAndPropertyTypeTest extends BaseTest
{
public function testVariadicPromotedPropertyIsRejected(): void
{
$this->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');
}
}
14 changes: 14 additions & 0 deletions src/Generator/ClosureGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
67 changes: 64 additions & 3 deletions src/Preprocessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/Resolver/NameResolutionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}
}
}
}
Loading