fix(type): validate compound type declarations

master
韩天峰 1 day ago
parent 1e70321553
commit b4919c7e76
  1. 290
      phpunit/src/CompoundTypeDeclarationValidationTest.php
  2. 15
      src/CompilerBase.php
  3. 6
      src/Generator/TypeCheckGenerator.php
  4. 55
      src/Preprocessor.php
  5. 34
      src/Resolver/NameResolutionTrait.php
  6. 271
      src/TypeSystem/CompoundTypeDeclarationValidationTrait.php

@ -0,0 +1,290 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
use TypePhp\CompilerTest;
use TypePhp\Exception\TestError;
/**
* Compound declarations must be rejected by the TypePHP front end, rather
* than leaking into gen_stub.php or the C++ compiler.
* @internal
* @coversNothing
*/
final class CompoundTypeDeclarationValidationTest extends PHPUnit\Framework\TestCase
{
private string $testRoot;
protected function setUp(): void
{
$this->testRoot = sys_get_temp_dir() . '/typephp-compound-type-' . bin2hex(random_bytes(8));
mkdir($this->testRoot, 0777, true);
}
protected function tearDown(): void
{
if (!is_dir($this->testRoot)) {
return;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->testRoot, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $entry) {
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
}
rmdir($this->testRoot);
}
/** @dataProvider invalidNamedDeclarationProvider */
public function testInvalidNamedDeclarationFailsDuringPrepare(string $declaration, string $diagnostic): void
{
[$compiler, $file] = $this->compilerFor("<?php\n{$declaration}\nfunction main(): void {}\n");
$this->expectException(TestError::class);
$this->expectExceptionMessage($diagnostic);
$compiler->prepareFile($file);
}
public static function invalidNamedDeclarationProvider(): iterable
{
yield 'duplicate builtin union member' => [
'function broken(int|string|int $value): void {}',
'Duplicate type int is redundant',
];
yield 'duplicate resolved class union member' => [
'namespace App; use Vendor\Model as Item; function broken(Item|\Vendor\Model $value): void {}',
'Duplicate type Vendor\Model is redundant',
];
yield 'iterable includes array' => [
'function broken(iterable|array $value): void {}',
'Duplicate type array is redundant',
];
yield 'iterable includes Traversable' => [
'function broken(\Traversable|iterable $value): void {}',
'Duplicate type Traversable is redundant',
];
yield 'bool includes false' => [
'function broken(false|bool $value): void {}',
'Duplicate type false is redundant',
];
yield 'true and false must use bool' => [
'function broken(true|false $value): void {}',
'Type contains both true and false, bool must be used instead',
];
yield 'mixed in union' => [
'function broken(mixed|string $value): void {}',
'Type mixed can only be used as a standalone type',
];
yield 'void in union' => [
'function broken(): void|string {}',
'Type void can only be used as a standalone type',
];
yield 'never in union' => [
'function broken(): never|string {}',
'Type never can only be used as a standalone type',
];
yield 'nullable mixed' => [
'function broken(?mixed $value): void {}',
'Type mixed cannot be marked as nullable since mixed already includes null',
];
yield 'nullable null' => [
'function broken(?null $value): void {}',
'null cannot be marked as nullable',
];
yield 'nullable void' => [
'function broken(): ?void {}',
'Void can only be used as a standalone type',
];
yield 'nullable never' => [
'function broken(): ?never {}',
'never can only be used as a standalone type',
];
yield 'scalar intersection member' => [
'function broken(A&int $value): void {}',
'Type int cannot be part of an intersection type',
];
yield 'callable intersection member' => [
'function broken(A&callable $value): void {}',
'Type callable cannot be part of an intersection type',
];
yield 'duplicate resolved intersection member' => [
'namespace App; use Vendor\Contract as C; function broken(C&\Vendor\Contract $value): void {}',
'Duplicate type Vendor\Contract is redundant',
];
yield 'permuted duplicate DNF member' => [
'function broken((A&B)|(B&A) $value): void {}',
'Type B&A is redundant with type A&B',
];
yield 'DNF strict superset after subset' => [
'function broken((A&B)|(A&B&C) $value): void {}',
'Type A&B&C is redundant as it is more restrictive than type A&B',
];
yield 'DNF strict superset before subset' => [
'function broken((A&B&C)|(A&B) $value): void {}',
'Type A&B&C is redundant as it is more restrictive than type A&B',
];
yield 'plain class subsumes DNF member' => [
'function broken((A&B)|A $value): void {}',
'Type A&B is redundant as it is more restrictive than type A',
];
yield 'object subsumes class' => [
'function broken(object|A $value): void {}',
'contains both object and a class type, which is redundant',
];
yield 'object subsumes DNF member' => [
'function broken(object|(A&B) $value): void {}',
'contains both object and a class type, which is redundant',
];
yield 'self in global function' => [
'function broken(): self {}',
'Cannot use "self" when no class scope is active',
];
yield 'self in global function union' => [
'function broken(): self|A {}',
'Cannot use "self" when no class scope is active',
];
yield 'self in global function DNF parameter' => [
'function broken((self&A)|B $value): void {}',
'Cannot use "self" when no class scope is active',
];
yield 'static in global function' => [
'function broken(): static {}',
'Cannot use "static" when no class scope is active',
];
yield 'static in global function union' => [
'function broken(): static|A {}',
'Cannot use "static" when no class scope is active',
];
yield 'parent in global function' => [
'function broken(): parent {}',
'Cannot use "parent" when no class scope is active',
];
yield 'parent method without parent class' => [
'class A { public function broken(): parent {} }',
'Cannot use "parent" when current class scope has no parent',
];
yield 'parent property without parent class' => [
'class A { public parent $value; }',
'Cannot use "parent" when current class scope has no parent',
];
yield 'parent constant without parent class' => [
'class A { public const parent VALUE = null; }',
'Cannot use "parent" when current class scope has no parent',
];
yield 'self in intersection inside class' => [
'class A { public function broken(self&B $value): void {} }',
"Type 'self' cannot be part of an intersection type",
];
yield 'self in DNF promoted property' => [
'class A { public function __construct(public (self&B)|C $value) {} }',
"Type 'self' cannot be part of an intersection type",
];
yield 'static in intersection return type' => [
'class A { public function broken(): static&B {} }',
"Type 'static' cannot be part of an intersection type",
];
yield 'duplicate class implements' => [
'interface I {} class A implements I, I {}',
'Class A cannot implement previously implemented interface I',
];
yield 'duplicate enum implements through alias' => [
'namespace App; interface I {} use App\I as Contract; enum E implements I, Contract { case A; }',
'Enum App\E cannot implement previously implemented interface App\I',
];
}
/** @dataProvider invalidClosureDeclarationProvider */
public function testInvalidClosureDeclarationFailsDuringConvert(string $body, string $diagnostic): void
{
[$compiler, $file] = $this->compilerFor("<?php\nfunction main(): void {\n{$body}\n}\n");
$compiler->prepareFile($file);
$this->expectException(TestError::class);
$this->expectExceptionMessage($diagnostic);
$compiler->convertFile($file);
}
public static function invalidClosureDeclarationProvider(): iterable
{
yield 'closure duplicate union' => [
'$fn = function (int|string|int $value): void {};',
'Duplicate type int is redundant',
];
yield 'arrow function invalid intersection' => [
'$fn = fn (A&int $value): int => 1;',
'Type int cannot be part of an intersection type',
];
yield 'closure permuted DNF' => [
'$fn = function ((A&B)|(B&A) $value): void {};',
'Type B&A is redundant with type A&B',
];
yield 'global closure self intersection' => [
'$fn = function (self&A $value): void {};',
"Type 'self' cannot be part of an intersection type",
];
}
public function testValidBoundaryDeclarationsCompile(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
namespace App;
interface A {}
interface B {}
interface C {}
interface Traversable {}
class Base {}
class Child extends Base
{
public function choose((A&B)|(A&C)|self|parent $value): (A&B)|(A&C)|self|parent|static
{
return $value;
}
}
function localNameDoesNotOverlapBuiltin(iterable|Traversable $value): void {}
function main(): void {}
PHP);
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
public function testGlobalClosuresKeepBindableSelfAndStaticTypes(): void
{
[$compiler, $file] = $this->compilerFor(<<<'PHP'
<?php
function main(): void
{
$withSelf = function (self $value): self { return $value; };
$withStatic = function (): static { throw new Exception('not invoked'); };
}
PHP);
$compiler->prepareFile($file);
$compiler->convertFile($file);
self::assertFileExists($compiler->getCppFile($file));
}
/** @return array{CompilerTest, string} */
private function compilerFor(string $source): array
{
$file = $this->testRoot . '/program.php';
file_put_contents($file, $source);
global $translator;
$compiler = CompilerTest::create($this->testRoot);
$translator = $compiler;
$compiler->addFiles([$file]);
return [$compiler, $file];
}
}

@ -82,6 +82,7 @@ use TypePhp\Resolver\PropertyAccessResolver;
use TypePhp\Resolver\Reflection; use TypePhp\Resolver\Reflection;
use TypePhp\Symbol\SymbolRepository; use TypePhp\Symbol\SymbolRepository;
use TypePhp\TypeSystem\CompositeTypeCheckerTrait; use TypePhp\TypeSystem\CompositeTypeCheckerTrait;
use TypePhp\TypeSystem\CompoundTypeDeclarationValidationTrait;
use TypePhp\TypeSystem\NativeTypeCompatibilityTrait; use TypePhp\TypeSystem\NativeTypeCompatibilityTrait;
use TypePhp\NativeClass\NativeClassSupportTrait; use TypePhp\NativeClass\NativeClassSupportTrait;
use TypePhp\NativeClass\NativeGlobalTypeResolver; use TypePhp\NativeClass\NativeGlobalTypeResolver;
@ -103,6 +104,7 @@ use PhpParser\PrettyPrinter;
class CompilerBase implements PropertyAccessContext class CompilerBase implements PropertyAccessContext
{ {
use CompositeTypeCheckerTrait; use CompositeTypeCheckerTrait;
use CompoundTypeDeclarationValidationTrait;
use CompilerDiagnosticTrait; use CompilerDiagnosticTrait;
use CompilationStateTrait; use CompilationStateTrait;
use NativeTypeCompatibilityTrait; use NativeTypeCompatibilityTrait;
@ -1507,11 +1509,22 @@ class CompilerBase implements PropertyAccessContext
protected function parseImplements(array $implements): array protected function parseImplements(array $implements): array
{ {
$list = []; $list = [];
$seen = [];
foreach ($implements as $implement) { foreach ($implements as $implement) {
$interfaceName = $this->getNamespacedClassName($this->parseIdentifier($implement)); $interfaceName = $this->getNamespacedClassName($this->parseIdentifier($implement));
$interfaceNameLower = strtolower($interfaceName);
if (isset($seen[$interfaceNameLower])) {
$kind = $this->classDef?->enum ? 'Enum' : 'Class';
$className = $this->classDef?->getNamespacedName(false) ?? $this->class;
$this->fatalError(
$implement,
"{$kind} {$className} cannot implement previously implemented interface {$interfaceName}",
);
}
$seen[$interfaceNameLower] = true;
$list[] = $interfaceName; $list[] = $interfaceName;
if (!$this->isInternalInterface($interfaceName)) { if (!$this->isInternalInterface($interfaceName)) {
$this->symbolCallInFile[$this->file][] = strtolower($interfaceName); $this->symbolCallInFile[$this->file][] = $interfaceNameLower;
} }
} }
return $list; return $list;

@ -156,12 +156,6 @@ trait TypeCheckGenerator
$check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause];
} }
} elseif ($typeNode instanceof IntersectionType) { } elseif ($typeNode instanceof IntersectionType) {
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");
}
}
$clause = $this->buildTypeCheckClause($typeNode); $clause = $this->buildTypeCheckClause($typeNode);
if (!empty($clause)) { if (!empty($clause)) {
$check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause];

@ -1238,6 +1238,50 @@ class Preprocessor extends CompilerBase
return $functionDef->getNamespacedName(); return $functionDef->getNamespacedName();
} }
/**
* self/parent/static in named declarations are resolved against the
* lexical class-like scope. Closures are intentionally excluded: PHP lets
* an otherwise global closure acquire such a scope through bindTo().
*/
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\Name) {
return;
}
$name = strtolower($type->toString());
if (!in_array($name, ['self', 'parent', 'static'], true)) {
return;
}
if (!$classScope) {
$this->fatalError($type, "Cannot use \"{$name}\" when no class scope is active");
}
if ($name === 'parent' && !$hasParent) {
$this->fatalError($type, 'Cannot use "parent" when current class scope has no parent');
}
}
private function currentClassScopeHasParent(): bool
{
// A trait does not know its eventual parent during preprocessing; PHP
// therefore permits parent in the trait and validates it when used.
return $this->classDef !== null
&& ($this->classDef->extends !== '' || $this->classDef->trait !== null);
}
protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): FunctionDef protected function parseFunctionDecl(Node\Stmt\Function_|Node\Stmt\ClassMethod $v): FunctionDef
{ {
// Local stubs define C++ native functions and require an explicit ABI return type. // Local stubs define C++ native functions and require an explicit ABI return type.
@ -1260,6 +1304,13 @@ class Preprocessor extends CompilerBase
} }
} }
$classScope = $v instanceof Node\Stmt\ClassMethod;
$hasParent = $classScope && $this->currentClassScopeHasParent();
$this->validateClassScopeTypeKeywords($v->returnType, $classScope, $hasParent);
foreach ($v->params as $param) {
$this->validateClassScopeTypeKeywords($param->type, $classScope, $hasParent);
}
$fnName = $this->parseIdentifier($v->name); $fnName = $this->parseIdentifier($v->name);
$this->markLateBoundTypeNodes($v->returnType); $this->markLateBoundTypeNodes($v->returnType);
// Capture the late-bound return type keyword *before* resolveTypeDecl runs, // Capture the late-bound return type keyword *before* resolveTypeDecl runs,
@ -1878,6 +1929,7 @@ class Preprocessor extends CompilerBase
{ {
$this->resetFunction(); $this->resetFunction();
$flags = $this->parseModifiers($v->flags); $flags = $this->parseModifiers($v->flags);
$this->validateClassScopeTypeKeywords($v->type, true, $this->currentClassScopeHasParent());
[$declaredType, $class] = $v->type [$declaredType, $class] = $v->type
? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST)
: [null, '']; : [null, ''];
@ -2011,6 +2063,7 @@ class Preprocessor extends CompilerBase
'Final promoted property must explicitly declare public, protected, or private visibility', 'Final promoted property must explicitly declare public, protected, or private visibility',
); );
} }
$this->validateClassScopeTypeKeywords($typeNode, true, $this->currentClassScopeHasParent());
$flags = $this->parseModifiers($flags); $flags = $this->parseModifiers($flags);
// A `readonly class` marks every property readonly, so the class-level // A `readonly class` marks every property readonly, so the class-level
// flag participates in the same Zend declaration rules as an explicit // flag participates in the same Zend declaration rules as an explicit
@ -2926,6 +2979,7 @@ class Preprocessor extends CompilerBase
); );
} }
if ($stmt->type) { if ($stmt->type) {
$this->validateClassScopeTypeKeywords($stmt->type, true, false);
[$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST);
if ($this->typeDeclContainsCallable($stmt->type)) { if ($this->typeDeclContainsCallable($stmt->type)) {
$this->fatalError( $this->fatalError(
@ -3062,6 +3116,7 @@ class Preprocessor extends CompilerBase
} }
} }
$this->validateClassScopeTypeKeywords($property->type, true, false);
[$type, $class] = $this->resolveTypeDecl($property->type, self::DECL_TYPE_OF_PROPERTY); [$type, $class] = $this->resolveTypeDecl($property->type, self::DECL_TYPE_OF_PROPERTY);
$nullable = $property->type instanceof NullableType; $nullable = $property->type instanceof NullableType;
foreach ($property->props as $prop) { foreach ($property->props as $prop) {

@ -168,7 +168,7 @@ trait NameResolutionTrait
if ($type === null) { if ($type === null) {
return Type::VAR; return Type::VAR;
} }
$this->assertTypeDeclIntersectionsHaveNoCallable($type); $this->validateCompoundTypeDeclaration($type);
if ($type instanceof UnionType || $type instanceof NullableType || $type instanceof IntersectionType) { 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. // Complex types are uniformly treated as mixed/var at the static stage; the runtime typeCheck provides the fallback.
return Type::VAR; return Type::VAR;
@ -203,36 +203,4 @@ trait NameResolutionTrait
} }
} }
/**
* 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');
}
}
}
}
} }

@ -0,0 +1,271 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\TypeSystem;
use PhpParser\Node;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\NullableType;
use PhpParser\Node\UnionType;
use PhpParser\NodeAbstract;
/**
* Validates the structure of PHP compound type declarations before they are
* lowered to TypePHP's runtime type checks.
*
* This deliberately has no knowledge of the declaration context. Rules for
* self/parent/static outside a class-like scope remain in the preprocessor so
* closures, whose scope may be supplied later by Closure::bindTo(), are not
* incorrectly treated as ordinary global functions.
*/
trait CompoundTypeDeclarationValidationTrait
{
/** @var array<string, true> */
private const array PHP_INTERSECTION_FORBIDDEN_TYPES = [
'array' => true,
'bool' => true,
'callable' => true,
'false' => true,
'float' => true,
'int' => true,
'iterable' => true,
'mixed' => true,
'never' => true,
'null' => true,
'object' => true,
'parent' => true,
'self' => true,
'static' => true,
'string' => true,
'true' => true,
'void' => true,
];
protected function validateCompoundTypeDeclaration(?NodeAbstract $type): void
{
if ($type instanceof NullableType) {
$name = strtolower($this->parseIdentifier($type->type));
if ($name === 'mixed') {
$this->fatalError($type, 'Type mixed cannot be marked as nullable since mixed already includes null');
}
if ($name === 'null') {
$this->fatalError($type, 'null cannot be marked as nullable');
}
if ($name === 'void') {
$this->fatalError($type, 'Void can only be used as a standalone type');
}
if ($name === 'never') {
$this->fatalError($type, 'never can only be used as a standalone type');
}
return;
}
if ($type instanceof IntersectionType) {
$this->validateIntersectionTypeDeclaration($type);
return;
}
if ($type instanceof UnionType) {
$this->validateUnionTypeDeclaration($type);
}
}
/**
* @return array{members: array<string, true>, display: string}
*/
private function validateIntersectionTypeDeclaration(IntersectionType $type): array
{
$members = [];
$display = [];
foreach ($type->types as $member) {
[$key, $name, $classLike] = $this->getCompoundTypeMemberIdentity($member);
$lowerName = strtolower($name);
if (!$classLike || isset(self::PHP_INTERSECTION_FORBIDDEN_TYPES[$lowerName])) {
$message = in_array($lowerName, ['self', 'parent', 'static'], true)
? "Type '{$lowerName}' cannot be part of an intersection type"
: "Type {$name} cannot be part of an intersection type";
$this->fatalError($member, $message);
}
if (isset($members[$key])) {
$this->fatalError($member, "Duplicate type {$name} is redundant");
}
$members[$key] = true;
$display[] = $name;
}
return ['members' => $members, 'display' => implode('&', $display)];
}
private function validateUnionTypeDeclaration(UnionType $type): void
{
/** @var array<string, true> $seen */
$seen = [];
/** @var list<array{members: array<string, true>, display: string}> $classGroups */
$classGroups = [];
$hasIterable = false;
$hasArray = false;
$hasTraversable = false;
$hasBool = false;
$hasTrue = false;
$hasFalse = false;
$hasObject = false;
$hasClassType = false;
foreach ($type->types as $member) {
if ($member instanceof IntersectionType) {
$group = $this->validateIntersectionTypeDeclaration($member);
$this->assertDnfGroupIsNotRedundant($member, $group, $classGroups);
$classGroups[] = $group;
$hasClassType = true;
continue;
}
[$key, $name, $classLike] = $this->getCompoundTypeMemberIdentity($member);
$lowerName = strtolower($name);
if (in_array($lowerName, ['mixed', 'void', 'never'], true)) {
$this->fatalError($member, "Type {$name} can only be used as a standalone type");
}
if (isset($seen[$key])) {
$this->fatalError($member, "Duplicate type {$name} is redundant");
}
if ($lowerName === 'iterable') {
if ($hasArray) {
$this->fatalError($member, 'Duplicate type array is redundant');
}
if ($hasTraversable) {
$this->fatalError($member, 'Duplicate type Traversable is redundant');
}
$hasIterable = true;
} elseif ($lowerName === 'array') {
if ($hasIterable) {
$this->fatalError($member, 'Duplicate type array is redundant');
}
$hasArray = true;
} elseif ($key === 'class:traversable') {
if ($hasIterable) {
$this->fatalError($member, 'Duplicate type Traversable is redundant');
}
$hasTraversable = true;
}
if ($lowerName === 'bool') {
if ($hasTrue) {
$this->fatalError($member, 'Duplicate type true is redundant');
}
if ($hasFalse) {
$this->fatalError($member, 'Duplicate type false is redundant');
}
$hasBool = true;
} elseif ($lowerName === 'true') {
if ($hasBool) {
$this->fatalError($member, 'Duplicate type true is redundant');
}
if ($hasFalse) {
$this->fatalError($member, 'Type contains both true and false, bool must be used instead');
}
$hasTrue = true;
} elseif ($lowerName === 'false') {
if ($hasBool) {
$this->fatalError($member, 'Duplicate type false is redundant');
}
if ($hasTrue) {
$this->fatalError($member, 'Type contains both true and false, bool must be used instead');
}
$hasFalse = true;
}
if ($lowerName === 'object') {
$hasObject = true;
} elseif ($classLike) {
$hasClassType = true;
$group = ['members' => [$key => true], 'display' => $name];
$this->assertDnfGroupIsNotRedundant($member, $group, $classGroups);
$classGroups[] = $group;
}
$seen[$key] = true;
}
if ($hasObject && $hasClassType) {
$this->fatalError($type, 'Type ' . $this->compoundTypeToString($type) . ' contains both object and a class type, which is redundant');
}
}
/**
* @param array{members: array<string, true>, display: string} $group
* @param list<array{members: array<string, true>, display: string}> $previousGroups
*/
private function assertDnfGroupIsNotRedundant(NodeAbstract $node, array $group, array $previousGroups): void
{
foreach ($previousGroups as $previous) {
$sameMembers = count($group['members']) === count($previous['members'])
&& array_diff_key($group['members'], $previous['members']) === [];
if ($sameMembers) {
$this->fatalError(
$node,
"Type {$group['display']} is redundant with type {$previous['display']}",
);
}
$groupContainsPrevious = array_diff_key($previous['members'], $group['members']) === [];
$previousContainsGroup = array_diff_key($group['members'], $previous['members']) === [];
if ($groupContainsPrevious || $previousContainsGroup) {
$moreRestrictive = count($group['members']) > count($previous['members']) ? $group : $previous;
$lessRestrictive = $moreRestrictive === $group ? $previous : $group;
$this->fatalError(
$node,
"Type {$moreRestrictive['display']} is redundant as it is more restrictive than type {$lessRestrictive['display']}",
);
}
}
}
/**
* @return array{string, string, bool} canonical key, display name, class-like
*/
private function getCompoundTypeMemberIdentity(NodeAbstract $member): array
{
$name = $this->parseIdentifier($member);
$lowerName = strtolower($name);
if (in_array($lowerName, ['self', 'parent', 'static'], true)) {
return ['class:' . $lowerName, $lowerName, true];
}
if ($member instanceof Node\Identifier || isset($this->zendTypeMap[$lowerName])) {
return ['builtin:' . $lowerName, $lowerName, false];
}
if ($member instanceof Node\Name) {
$resolvedName = $member->getAttribute('resolvedName');
if ($resolvedName instanceof Node\Name) {
$name = $resolvedName->toString();
} elseif ($member instanceof Node\Name\FullyQualified) {
$name = $member->toString();
} else {
$name = $this->getNamespacedClassName($name);
}
}
return ['class:' . strtolower(ltrim($name, '\\')), ltrim($name, '\\'), true];
}
private function compoundTypeToString(NodeAbstract $type): string
{
if ($type instanceof UnionType) {
return implode('|', array_map(fn (NodeAbstract $member): string => $this->compoundTypeToString($member), $type->types));
}
if ($type instanceof IntersectionType) {
return implode('&', array_map(fn (NodeAbstract $member): string => $this->compoundTypeToString($member), $type->types));
}
if ($type instanceof NullableType) {
return '?' . $this->compoundTypeToString($type->type);
}
[, $name] = $this->getCompoundTypeMemberIdentity($type);
return $name;
}
}
Loading…
Cancel
Save