Skip to content
Open
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
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_bool_false.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(bool|false $x): void {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/type_rule_dup_class_union.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
class Foo {}
function f(Foo|Foo $x): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_dup_union.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(int|string|int $x): void {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/type_rule_implements_dup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
interface Ia {}
class C implements Ia, Ia {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/type_rule_intersect_dup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
interface Ix {}
function f(Ix&Ix $x): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_intersect_scalar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(int&string $x): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_iterable_array.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(iterable|array $x): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_mixed_union.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(mixed|int $x): void {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_nullable_mixed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(?mixed $x): void {}

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

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

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_true_false.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(true|false $x): void {}

function main() {}
6 changes: 6 additions & 0 deletions phpunit/code/type_rule_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
interface Ia {}
interface Ib {}
class TypeOk { public int|string $u; public function m(int|false $a, iterable $c, (Ia&Ib)|string $d): static { return $this; } }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/type_rule_void_union.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
function f(): void|int {}

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

/**
* Compound type well-formedness, shared across parameters, returns,
* properties, and constants: duplicate members, bool/true/false
* overlap, standalone-only types, nullable restrictions, intersection
* member rules, class-scope type keywords, and duplicate implements.
*/
class CompoundTypeValidationTest extends BaseTest
{
public function testDuplicateUnionMemberIsRejected(): void
{
$this->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');
}
}
180 changes: 180 additions & 0 deletions src/Preprocessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,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(
Expand Down Expand Up @@ -1258,6 +1267,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
Expand Down Expand Up @@ -1746,6 +1768,164 @@ 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.
*/
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
Loading