diff --git a/phpunit/src/CompoundTypeDeclarationValidationTest.php b/phpunit/src/CompoundTypeDeclarationValidationTest.php new file mode 100644 index 00000000..e3f84458 --- /dev/null +++ b/phpunit/src/CompoundTypeDeclarationValidationTest.php @@ -0,0 +1,290 @@ +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("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("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' +prepareFile($file); + $compiler->convertFile($file); + + self::assertFileExists($compiler->getCppFile($file)); + } + + public function testGlobalClosuresKeepBindableSelfAndStaticTypes(): void + { + [$compiler, $file] = $this->compilerFor(<<<'PHP' +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]; + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index a437a2ca..cac3c1e6 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -82,6 +82,7 @@ use TypePhp\Resolver\PropertyAccessResolver; use TypePhp\Resolver\Reflection; use TypePhp\Symbol\SymbolRepository; use TypePhp\TypeSystem\CompositeTypeCheckerTrait; +use TypePhp\TypeSystem\CompoundTypeDeclarationValidationTrait; use TypePhp\TypeSystem\NativeTypeCompatibilityTrait; use TypePhp\NativeClass\NativeClassSupportTrait; use TypePhp\NativeClass\NativeGlobalTypeResolver; @@ -103,6 +104,7 @@ use PhpParser\PrettyPrinter; class CompilerBase implements PropertyAccessContext { use CompositeTypeCheckerTrait; + use CompoundTypeDeclarationValidationTrait; use CompilerDiagnosticTrait; use CompilationStateTrait; use NativeTypeCompatibilityTrait; @@ -1507,11 +1509,22 @@ class CompilerBase implements PropertyAccessContext protected function parseImplements(array $implements): array { $list = []; + $seen = []; foreach ($implements as $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; if (!$this->isInternalInterface($interfaceName)) { - $this->symbolCallInFile[$this->file][] = strtolower($interfaceName); + $this->symbolCallInFile[$this->file][] = $interfaceNameLower; } } return $list; diff --git a/src/Generator/TypeCheckGenerator.php b/src/Generator/TypeCheckGenerator.php index 87a9d897..60d0091a 100644 --- a/src/Generator/TypeCheckGenerator.php +++ b/src/Generator/TypeCheckGenerator.php @@ -156,12 +156,6 @@ trait TypeCheckGenerator $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; } } 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); if (!empty($clause)) { $check[] = count($clause) === 1 ? $clause[0] : ['kind' => 'allOf', 'types' => $clause]; diff --git a/src/Preprocessor.php b/src/Preprocessor.php index da7a4764..d4ee7bbc 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1238,6 +1238,50 @@ class Preprocessor extends CompilerBase 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 { // 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); $this->markLateBoundTypeNodes($v->returnType); // Capture the late-bound return type keyword *before* resolveTypeDecl runs, @@ -1878,6 +1929,7 @@ class Preprocessor extends CompilerBase { $this->resetFunction(); $flags = $this->parseModifiers($v->flags); + $this->validateClassScopeTypeKeywords($v->type, true, $this->currentClassScopeHasParent()); [$declaredType, $class] = $v->type ? $this->resolveTypeDecl($v->type, self::DECL_TYPE_OF_CONST) : [null, '']; @@ -2011,6 +2063,7 @@ class Preprocessor extends CompilerBase 'Final promoted property must explicitly declare public, protected, or private visibility', ); } + $this->validateClassScopeTypeKeywords($typeNode, true, $this->currentClassScopeHasParent()); $flags = $this->parseModifiers($flags); // A `readonly class` marks every property readonly, so the class-level // flag participates in the same Zend declaration rules as an explicit @@ -2926,6 +2979,7 @@ class Preprocessor extends CompilerBase ); } if ($stmt->type) { + $this->validateClassScopeTypeKeywords($stmt->type, true, false); [$type, $class] = $this->resolveTypeDecl($stmt->type, self::DECL_TYPE_OF_CONST); if ($this->typeDeclContainsCallable($stmt->type)) { $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); $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { diff --git a/src/Resolver/NameResolutionTrait.php b/src/Resolver/NameResolutionTrait.php index ef2eba5e..699307e7 100644 --- a/src/Resolver/NameResolutionTrait.php +++ b/src/Resolver/NameResolutionTrait.php @@ -168,7 +168,7 @@ trait NameResolutionTrait if ($type === null) { return Type::VAR; } - $this->assertTypeDeclIntersectionsHaveNoCallable($type); + $this->validateCompoundTypeDeclaration($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; @@ -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'); - } - } - } - } } diff --git a/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php b/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php new file mode 100644 index 00000000..b90f5d69 --- /dev/null +++ b/src/TypeSystem/CompoundTypeDeclarationValidationTrait.php @@ -0,0 +1,271 @@ + */ + 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, 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 $seen */ + $seen = []; + /** @var list, 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, display: string} $group + * @param list, 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; + } +}